Linting cleanup (#99)

* Add eslint and prettier dependencies and configs

* Lint project.
This commit is contained in:
Antonis Anastasiadis 2025-07-01 11:40:09 +03:00 committed by GitHub
parent dd6ec117d0
commit e594d1075b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 26554 additions and 39840 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
.idea
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
@ -27,4 +28,4 @@ dist/
build/
# Webpack output
public/assets/
public/assets/

6
backend/.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}

View file

@ -15,64 +15,91 @@ const app = express();
// Session store
const sessionStore = new SequelizeStore({
db: sequelize,
db: sequelize,
});
// Middlewares
const sslEnabled = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
app.use(helmet({
hsts: sslEnabled, // Only enable HSTS when SSL is enabled
forceHTTPS: sslEnabled, // Only force HTTPS when SSL is enabled
contentSecurityPolicy: false // Disable CSP for now to avoid conflicts
}));
const sslEnabled =
process.env.NODE_ENV === 'production' &&
process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
app.use(
helmet({
hsts: sslEnabled, // Only enable HSTS when SSL is enabled
forceHTTPS: sslEnabled, // Only force HTTPS when SSL is enabled
contentSecurityPolicy: false, // Disable CSP for now to avoid conflicts
})
);
app.use(compression());
app.use(morgan('combined'));
// CORS configuration
const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map(origin => origin.trim())
: ['http://localhost:8080', 'http://localhost:9292', 'http://127.0.0.1:8080', 'http://127.0.0.1:9292'];
const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
? process.env.TUDUDI_ALLOWED_ORIGINS.split(',').map((origin) =>
origin.trim()
)
: [
'http://localhost:8080',
'http://localhost:9292',
'http://127.0.0.1:8080',
'http://127.0.0.1:9292',
];
app.use(cors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'Accept', 'X-Requested-With'],
exposedHeaders: ['Content-Type'],
maxAge: 1728000
}));
app.use(
cors({
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Authorization',
'Content-Type',
'Accept',
'X-Requested-With',
],
exposedHeaders: ['Content-Type'],
maxAge: 1728000,
})
);
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Session configuration
const secureFlag = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
app.use(session({
secret: process.env.TUDUDI_SESSION_SECRET || require('crypto').randomBytes(64).toString('hex'),
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: secureFlag,
maxAge: 2592000000, // 30 days
sameSite: secureFlag ? 'none' : 'lax'
}
}));
const secureFlag =
process.env.NODE_ENV === 'production' &&
process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
app.use(
session({
secret:
process.env.TUDUDI_SESSION_SECRET ||
require('crypto').randomBytes(64).toString('hex'),
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: secureFlag,
maxAge: 2592000000, // 30 days
sameSite: secureFlag ? 'none' : 'lax',
},
})
);
// Static files
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'dist')));
app.use(express.static(path.join(__dirname, 'dist')));
} else {
app.use(express.static('public'));
app.use(express.static('public'));
}
// Serve locales
// Serve locales
if (process.env.NODE_ENV === 'production') {
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
} else {
app.use('/locales', express.static(path.join(__dirname, '../public/locales')));
app.use(
'/locales',
express.static(path.join(__dirname, '../public/locales'))
);
}
// Serve uploaded files
@ -83,12 +110,12 @@ const { requireAuth } = require('./middleware/auth');
// Health check (before auth middleware) - ensure it's completely bypassed
app.get('/api/health', (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development'
});
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development',
});
});
// Routes
@ -108,74 +135,86 @@ app.use('/api/calendar', require('./routes/calendar'));
// SPA fallback
app.get('*', (req, res) => {
if (!req.path.startsWith('/api/') && !req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)) {
if (process.env.NODE_ENV === 'production') {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
if (
!req.path.startsWith('/api/') &&
!req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)
) {
if (process.env.NODE_ENV === 'production') {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
} else {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
}
} else {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
res.status(404).json({
error: 'Not Found',
message: 'The requested resource could not be found.',
});
}
} else {
res.status(404).json({ error: 'Not Found', message: 'The requested resource could not be found.' });
}
});
// Error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error', message: err.message });
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: err.message,
});
});
const PORT = process.env.PORT || 3002;
// Initialize database and start server
async function startServer() {
try {
// Create session store table
await sessionStore.sync();
// Sync database
await sequelize.sync();
// Auto-create user if not exists
if (process.env.TUDUDI_USER_EMAIL && process.env.TUDUDI_USER_PASSWORD) {
const { User } = require('./models');
const bcrypt = require('bcrypt');
const [user, created] = await User.findOrCreate({
where: { email: process.env.TUDUDI_USER_EMAIL },
defaults: {
email: process.env.TUDUDI_USER_EMAIL,
password_digest: await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD, 10)
try {
// Create session store table
await sessionStore.sync();
// Sync database
await sequelize.sync();
// Auto-create user if not exists
if (process.env.TUDUDI_USER_EMAIL && process.env.TUDUDI_USER_PASSWORD) {
const { User } = require('./models');
const bcrypt = require('bcrypt');
const [user, created] = await User.findOrCreate({
where: { email: process.env.TUDUDI_USER_EMAIL },
defaults: {
email: process.env.TUDUDI_USER_EMAIL,
password_digest: await bcrypt.hash(
process.env.TUDUDI_USER_PASSWORD,
10
),
},
});
if (created) {
console.log('Default user created:', user.email);
}
}
});
if (created) {
console.log('Default user created:', user.email);
}
// Initialize Telegram polling after database is ready
await initializeTelegramPolling();
// Initialize task scheduler
await taskScheduler.initialize();
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
console.log(`Server listening on http://localhost:${PORT}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
// Initialize Telegram polling after database is ready
await initializeTelegramPolling();
// Initialize task scheduler
await taskScheduler.initialize();
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
console.log(`Server listening on http://localhost:${PORT}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
if (require.main === module) {
startServer();
startServer();
}
module.exports = app;
module.exports = app;

View file

@ -1,42 +1,42 @@
require('dotenv').config();
const path = require('path');
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(__dirname, '..', 'db');
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(__dirname, '..', 'db');
module.exports = {
development: {
dialect: 'sqlite',
storage: path.join(dbPath, 'development.sqlite3'),
logging: console.log,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
test: {
dialect: 'sqlite',
storage: path.join(dbPath, 'test.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
},
production: {
dialect: 'sqlite',
storage: path.join(dbPath, 'production.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
}
};
development: {
dialect: 'sqlite',
storage: path.join(dbPath, 'development.sqlite3'),
logging: console.log,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
},
},
test: {
dialect: 'sqlite',
storage: path.join(dbPath, 'test.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
},
},
production: {
dialect: 'sqlite',
storage: path.join(dbPath, 'production.sqlite3'),
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
},
},
};

View file

@ -1,22 +1,21 @@
quotes:
- "Believe you can and you're halfway there."
- "The only way to do great work is to love what you do."
- "Success is not final, failure is not fatal: It is the courage to continue that counts."
- "It always seems impossible until it's done."
- "Your time is limited, don't waste it living someone else's life."
- "The future belongs to those who believe in the beauty of their dreams."
- "Don't watch the clock; do what it does. Keep going."
- "Quality is not an act, it is a habit."
- "The only limit to our realization of tomorrow is our doubts of today."
- "Act as if what you do makes a difference. It does."
- "The best way to predict the future is to create it."
- "Success is walking from failure to failure with no loss of enthusiasm."
- "You are never too old to set another goal or to dream a new dream."
- "The secret of getting ahead is getting started."
- "Don't let yesterday take up too much of today."
- "You don't have to be great to start, but you have to start to be great."
- "Focus on progress, not perfection."
- "One task at a time leads to great accomplishments."
- "Today's effort is tomorrow's success."
- "Small steps every day lead to big results."
- "Believe you can and you're halfway there."
- 'The only way to do great work is to love what you do.'
- 'Success is not final, failure is not fatal: It is the courage to continue that counts.'
- "It always seems impossible until it's done."
- "Your time is limited, don't waste it living someone else's life."
- 'The future belongs to those who believe in the beauty of their dreams.'
- "Don't watch the clock; do what it does. Keep going."
- 'Quality is not an act, it is a habit.'
- 'The only limit to our realization of tomorrow is our doubts of today.'
- 'Act as if what you do makes a difference. It does.'
- 'The best way to predict the future is to create it.'
- 'Success is walking from failure to failure with no loss of enthusiasm.'
- 'You are never too old to set another goal or to dream a new dream.'
- 'The secret of getting ahead is getting started.'
- "Don't let yesterday take up too much of today."
- "You don't have to be great to start, but you have to start to be great."
- 'Focus on progress, not perfection.'
- 'One task at a time leads to great accomplishments.'
- "Today's effort is tomorrow's success."
- 'Small steps every day lead to big results.'

40
backend/eslint.config.js Normal file
View file

@ -0,0 +1,40 @@
module.exports = [
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
require: 'readonly',
module: 'readonly',
exports: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
process: 'readonly',
console: 'readonly',
Buffer: 'readonly',
global: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
},
},
plugins: {
prettier: require('eslint-plugin-prettier'),
jest: require('eslint-plugin-jest'),
},
rules: {
...require('eslint-plugin-prettier').configs.recommended.rules,
...require('eslint-plugin-jest').configs.recommended.rules,
},
},
{
files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'],
languageOptions: {
globals: {
...require('eslint-plugin-jest').environments.globals.globals,
},
},
},
];

View file

@ -1,25 +1,22 @@
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
testMatch: [
'<rootDir>/tests/**/*.test.js',
'<rootDir>/tests/**/*.spec.js'
],
maxWorkers: 1,
collectCoverageFrom: [
'routes/**/*.js',
'models/**/*.js',
'middleware/**/*.js',
'services/**/*.js',
'!models/index.js',
'!**/*.test.js',
'!**/*.spec.js'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true
};
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/tests/helpers/setup.js'],
testMatch: ['<rootDir>/tests/**/*.test.js', '<rootDir>/tests/**/*.spec.js'],
maxWorkers: 1,
collectCoverageFrom: [
'routes/**/*.js',
'models/**/*.js',
'middleware/**/*.js',
'services/**/*.js',
'!models/index.js',
'!**/*.test.js',
'!**/*.spec.js',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
};

View file

@ -1,31 +1,31 @@
const { User } = require('../models');
const requireAuth = async (req, res, next) => {
try {
// Skip authentication for health check, login routes, and current_user
const skipPaths = ['/api/health', '/api/login', '/api/current_user'];
if (skipPaths.includes(req.path) || req.originalUrl === '/api/health') {
return next();
}
try {
// Skip authentication for health check, login routes, and current_user
const skipPaths = ['/api/health', '/api/login', '/api/current_user'];
if (skipPaths.includes(req.path) || req.originalUrl === '/api/health') {
return next();
}
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
req.session.destroy();
return res.status(401).json({ error: 'User not found' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
req.session.destroy();
return res.status(401).json({ error: 'User not found' });
}
req.currentUser = user;
next();
} catch (error) {
console.error('Authentication error:', error);
res.status(500).json({ error: 'Authentication error' });
}
req.currentUser = user;
next();
} catch (error) {
console.error('Authentication error:', error);
res.status(500).json({ error: 'Authentication error' });
}
};
module.exports = {
requireAuth
};
requireAuth,
};

View file

@ -1,61 +1,61 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true
},
password: {
type: Sequelize.STRING,
allowNull: false
},
telegram_bot_token: {
type: Sequelize.STRING,
allowNull: true
},
telegram_chat_id: {
type: Sequelize.STRING,
allowNull: true
},
task_summary_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: false
},
task_summary_frequency: {
type: Sequelize.STRING,
defaultValue: 'daily'
},
task_summary_last_run: {
type: Sequelize.DATE,
allowNull: true
},
task_summary_next_run: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
},
async up(queryInterface, Sequelize) {
await queryInterface.createTable('users', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER,
},
email: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
password: {
type: Sequelize.STRING,
allowNull: false,
},
telegram_bot_token: {
type: Sequelize.STRING,
allowNull: true,
},
telegram_chat_id: {
type: Sequelize.STRING,
allowNull: true,
},
task_summary_enabled: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
task_summary_frequency: {
type: Sequelize.STRING,
defaultValue: 'daily',
},
task_summary_last_run: {
type: Sequelize.DATE,
allowNull: true,
},
task_summary_next_run: {
type: Sequelize.DATE,
allowNull: true,
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('users');
}
};
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('users');
},
};

View file

@ -1,47 +1,57 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Add new fields to support enhanced recurring task functionality
await queryInterface.addColumn('tasks', 'recurrence_weekday', {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence'
});
async up(queryInterface, Sequelize) {
// Add new fields to support enhanced recurring task functionality
await queryInterface.addColumn('tasks', 'recurrence_weekday', {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence',
});
await queryInterface.addColumn('tasks', 'recurrence_month_day', {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Day of month (1-31) for monthly recurrence, -1 for last day'
});
await queryInterface.addColumn('tasks', 'recurrence_month_day', {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Day of month (1-31) for monthly recurrence, -1 for last day',
});
await queryInterface.addColumn('tasks', 'recurrence_week_of_month', {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Week of month (1-5) for monthly weekday recurrence'
});
await queryInterface.addColumn('tasks', 'recurrence_week_of_month', {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Week of month (1-5) for monthly weekday recurrence',
});
await queryInterface.addColumn('tasks', 'completion_based', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether recurrence is based on completion date (true) or due date (false)'
});
await queryInterface.addColumn('tasks', 'completion_based', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment:
'Whether recurrence is based on completion date (true) or due date (false)',
});
// Add index for efficient recurring task queries
await queryInterface.addIndex('tasks', ['recurrence_type', 'last_generated_date'], {
name: 'idx_tasks_recurrence_lookup'
});
},
// Add index for efficient recurring task queries
await queryInterface.addIndex(
'tasks',
['recurrence_type', 'last_generated_date'],
{
name: 'idx_tasks_recurrence_lookup',
}
);
},
async down(queryInterface, Sequelize) {
// Remove the added columns
await queryInterface.removeColumn('tasks', 'recurrence_weekday');
await queryInterface.removeColumn('tasks', 'recurrence_month_day');
await queryInterface.removeColumn('tasks', 'recurrence_week_of_month');
await queryInterface.removeColumn('tasks', 'completion_based');
// Remove the index
await queryInterface.removeIndex('tasks', 'idx_tasks_recurrence_lookup');
}
};
async down(queryInterface, Sequelize) {
// Remove the added columns
await queryInterface.removeColumn('tasks', 'recurrence_weekday');
await queryInterface.removeColumn('tasks', 'recurrence_month_day');
await queryInterface.removeColumn('tasks', 'recurrence_week_of_month');
await queryInterface.removeColumn('tasks', 'completion_based');
// Remove the index
await queryInterface.removeIndex(
'tasks',
'idx_tasks_recurrence_lookup'
);
},
};

View file

@ -1,24 +1,24 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('tasks', 'recurring_parent_id', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
});
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('tasks', 'recurring_parent_id', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
// Add index for performance
await queryInterface.addIndex('tasks', ['recurring_parent_id']);
},
// Add index for performance
await queryInterface.addIndex('tasks', ['recurring_parent_id']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
await queryInterface.removeColumn('tasks', 'recurring_parent_id');
}
};
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
await queryInterface.removeColumn('tasks', 'recurring_parent_id');
},
};

View file

@ -1,14 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('projects', 'image_url', {
type: Sequelize.TEXT,
allowNull: true
});
},
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('projects', 'image_url', {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('projects', 'image_url');
}
};
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('projects', 'image_url');
},
};

View file

@ -1,15 +1,15 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'task_intelligence_enabled', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'task_intelligence_enabled', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'task_intelligence_enabled');
}
};
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'task_intelligence_enabled');
},
};

View file

@ -1,15 +1,22 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('users', 'auto_suggest_next_actions_enabled', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn(
'users',
'auto_suggest_next_actions_enabled',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
}
);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('users', 'auto_suggest_next_actions_enabled');
}
};
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn(
'users',
'auto_suggest_next_actions_enabled'
);
},
};

View file

@ -1,22 +1,22 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Add completed_at column to tasks table
await queryInterface.addColumn('tasks', 'completed_at', {
type: Sequelize.DATE,
allowNull: true
});
// Add an index for better query performance
await queryInterface.addIndex('tasks', ['completed_at']);
},
async up(queryInterface, Sequelize) {
// Add completed_at column to tasks table
await queryInterface.addColumn('tasks', 'completed_at', {
type: Sequelize.DATE,
allowNull: true,
});
async down(queryInterface, Sequelize) {
// Remove the index first
await queryInterface.removeIndex('tasks', ['completed_at']);
// Remove the completed_at column
await queryInterface.removeColumn('tasks', 'completed_at');
}
};
// Add an index for better query performance
await queryInterface.addIndex('tasks', ['completed_at']);
},
async down(queryInterface, Sequelize) {
// Remove the index first
await queryInterface.removeIndex('tasks', ['completed_at']);
// Remove the completed_at column
await queryInterface.removeColumn('tasks', 'completed_at');
},
};

View file

@ -1,79 +1,79 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('calendar_tokens', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onDelete: 'CASCADE'
},
provider: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'google'
},
access_token: {
type: Sequelize.TEXT,
allowNull: false
},
refresh_token: {
type: Sequelize.TEXT,
allowNull: true
},
token_type: {
type: Sequelize.STRING,
defaultValue: 'Bearer'
},
expires_at: {
type: Sequelize.DATE,
allowNull: true
},
scope: {
type: Sequelize.TEXT,
allowNull: true
},
connected_email: {
type: Sequelize.STRING,
allowNull: true
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('calendar_tokens', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
onDelete: 'CASCADE',
},
provider: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'google',
},
access_token: {
type: Sequelize.TEXT,
allowNull: false,
},
refresh_token: {
type: Sequelize.TEXT,
allowNull: true,
},
token_type: {
type: Sequelize.STRING,
defaultValue: 'Bearer',
},
expires_at: {
type: Sequelize.DATE,
allowNull: true,
},
scope: {
type: Sequelize.TEXT,
allowNull: true,
},
connected_email: {
type: Sequelize.STRING,
allowNull: true,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
// Add unique index for user_id + provider combination
await queryInterface.addIndex('calendar_tokens', {
fields: ['user_id', 'provider'],
unique: true,
name: 'calendar_tokens_user_provider_unique'
});
// Add unique index for user_id + provider combination
await queryInterface.addIndex('calendar_tokens', {
fields: ['user_id', 'provider'],
unique: true,
name: 'calendar_tokens_user_provider_unique',
});
// Add index for faster lookups by user_id
await queryInterface.addIndex('calendar_tokens', {
fields: ['user_id'],
name: 'calendar_tokens_user_id_index'
});
},
// Add index for faster lookups by user_id
await queryInterface.addIndex('calendar_tokens', {
fields: ['user_id'],
name: 'calendar_tokens_user_id_index',
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('calendar_tokens');
}
};
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('calendar_tokens');
},
};

View file

@ -1,88 +1,94 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Create task_events table
await queryInterface.createTable('task_events', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
task_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'tasks',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
event_type: {
type: Sequelize.STRING,
allowNull: false,
// Common event types: 'created', 'status_changed', 'priority_changed',
// 'due_date_changed', 'project_changed', 'name_changed', 'description_changed',
// 'completed', 'archived', 'deleted', 'restored'
},
old_value: {
type: Sequelize.TEXT,
allowNull: true,
// JSON string of the old value(s) - for tracking what changed from
},
new_value: {
type: Sequelize.TEXT,
allowNull: true,
// JSON string of the new value(s) - for tracking what changed to
},
field_name: {
type: Sequelize.STRING,
allowNull: true,
// The name of the field that was changed (status, priority, due_date, etc.)
},
metadata: {
type: Sequelize.TEXT,
allowNull: true,
// Additional context as JSON string (e.g., source of change: 'web', 'api', 'telegram')
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
async up(queryInterface, Sequelize) {
// Create task_events table
await queryInterface.createTable('task_events', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
task_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'tasks',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
event_type: {
type: Sequelize.STRING,
allowNull: false,
// Common event types: 'created', 'status_changed', 'priority_changed',
// 'due_date_changed', 'project_changed', 'name_changed', 'description_changed',
// 'completed', 'archived', 'deleted', 'restored'
},
old_value: {
type: Sequelize.TEXT,
allowNull: true,
// JSON string of the old value(s) - for tracking what changed from
},
new_value: {
type: Sequelize.TEXT,
allowNull: true,
// JSON string of the new value(s) - for tracking what changed to
},
field_name: {
type: Sequelize.STRING,
allowNull: true,
// The name of the field that was changed (status, priority, due_date, etc.)
},
metadata: {
type: Sequelize.TEXT,
allowNull: true,
// Additional context as JSON string (e.g., source of change: 'web', 'api', 'telegram')
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
// Add indexes for better query performance
await queryInterface.addIndex('task_events', ['task_id']);
await queryInterface.addIndex('task_events', ['user_id']);
await queryInterface.addIndex('task_events', ['event_type']);
await queryInterface.addIndex('task_events', ['created_at']);
await queryInterface.addIndex('task_events', ['task_id', 'event_type']);
await queryInterface.addIndex('task_events', ['task_id', 'created_at']);
},
// Add indexes for better query performance
await queryInterface.addIndex('task_events', ['task_id']);
await queryInterface.addIndex('task_events', ['user_id']);
await queryInterface.addIndex('task_events', ['event_type']);
await queryInterface.addIndex('task_events', ['created_at']);
await queryInterface.addIndex('task_events', ['task_id', 'event_type']);
await queryInterface.addIndex('task_events', ['task_id', 'created_at']);
},
async down(queryInterface, Sequelize) {
// Remove indexes first
await queryInterface.removeIndex('task_events', ['task_id', 'created_at']);
await queryInterface.removeIndex('task_events', ['task_id', 'event_type']);
await queryInterface.removeIndex('task_events', ['created_at']);
await queryInterface.removeIndex('task_events', ['event_type']);
await queryInterface.removeIndex('task_events', ['user_id']);
await queryInterface.removeIndex('task_events', ['task_id']);
// Drop the table
await queryInterface.dropTable('task_events');
}
};
async down(queryInterface, Sequelize) {
// Remove indexes first
await queryInterface.removeIndex('task_events', [
'task_id',
'created_at',
]);
await queryInterface.removeIndex('task_events', [
'task_id',
'event_type',
]);
await queryInterface.removeIndex('task_events', ['created_at']);
await queryInterface.removeIndex('task_events', ['event_type']);
await queryInterface.removeIndex('task_events', ['user_id']);
await queryInterface.removeIndex('task_events', ['task_id']);
// Drop the table
await queryInterface.dropTable('task_events');
},
};

View file

@ -2,15 +2,15 @@
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'pomodoro_enabled', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('users', 'pomodoro_enabled', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn('users', 'pomodoro_enabled');
}
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('users', 'pomodoro_enabled');
},
};

View file

@ -3,39 +3,39 @@
const { v4: uuidv4 } = require('uuid');
module.exports = {
async up(queryInterface, Sequelize) {
// Add UUID column to tasks table (without unique constraint initially)
await queryInterface.addColumn('tasks', 'uuid', {
type: Sequelize.UUID,
allowNull: true
});
async up(queryInterface, Sequelize) {
// Add UUID column to tasks table (without unique constraint initially)
await queryInterface.addColumn('tasks', 'uuid', {
type: Sequelize.UUID,
allowNull: true,
});
// Backfill existing tasks with UUIDs
const tasks = await queryInterface.sequelize.query(
'SELECT id FROM tasks WHERE uuid IS NULL',
{ type: Sequelize.QueryTypes.SELECT }
);
// Backfill existing tasks with UUIDs
const tasks = await queryInterface.sequelize.query(
'SELECT id FROM tasks WHERE uuid IS NULL',
{ type: Sequelize.QueryTypes.SELECT }
);
for (const task of tasks) {
const uuid = uuidv4();
await queryInterface.sequelize.query(
'UPDATE tasks SET uuid = ? WHERE id = ?',
{ replacements: [uuid, task.id] }
);
}
for (const task of tasks) {
const uuid = uuidv4();
await queryInterface.sequelize.query(
'UPDATE tasks SET uuid = ? WHERE id = ?',
{ replacements: [uuid, task.id] }
);
}
// Add unique index for UUID
await queryInterface.addIndex('tasks', ['uuid'], {
unique: true,
name: 'tasks_uuid_unique'
});
},
// Add unique index for UUID
await queryInterface.addIndex('tasks', ['uuid'], {
unique: true,
name: 'tasks_uuid_unique',
});
},
async down(queryInterface, Sequelize) {
// Remove index first
await queryInterface.removeIndex('tasks', 'tasks_uuid_unique');
// Remove UUID column
await queryInterface.removeColumn('tasks', 'uuid');
}
};
async down(queryInterface, Sequelize) {
// Remove index first
await queryInterface.removeIndex('tasks', 'tasks_uuid_unique');
// Remove UUID column
await queryInterface.removeColumn('tasks', 'uuid');
},
};

View file

@ -1,48 +1,48 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Check if notes_tags table exists
const tables = await queryInterface.showAllTables();
if (!tables.includes('notes_tags')) {
await queryInterface.createTable('notes_tags', {
note_id: {
type: Sequelize.INTEGER,
references: {
model: 'notes',
key: 'id'
},
onDelete: 'CASCADE'
},
tag_id: {
type: Sequelize.INTEGER,
references: {
model: 'tags',
key: 'id'
},
onDelete: 'CASCADE'
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
async up(queryInterface, Sequelize) {
// Check if notes_tags table exists
const tables = await queryInterface.showAllTables();
if (!tables.includes('notes_tags')) {
await queryInterface.createTable('notes_tags', {
note_id: {
type: Sequelize.INTEGER,
references: {
model: 'notes',
key: 'id',
},
onDelete: 'CASCADE',
},
tag_id: {
type: Sequelize.INTEGER,
references: {
model: 'tags',
key: 'id',
},
onDelete: 'CASCADE',
},
created_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
// Add unique index
await queryInterface.addIndex('notes_tags', ['note_id', 'tag_id'], {
unique: true,
name: 'notes_tags_unique_idx',
});
}
});
},
// Add unique index
await queryInterface.addIndex('notes_tags', ['note_id', 'tag_id'], {
unique: true,
name: 'notes_tags_unique_idx'
});
}
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('notes_tags');
}
};
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('notes_tags');
},
};

View file

@ -1,29 +1,29 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Add created_at and updated_at columns to notes_tags table
try {
await queryInterface.addColumn('notes_tags', 'created_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
});
await queryInterface.addColumn('notes_tags', 'updated_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
});
console.log('Successfully added timestamps to notes_tags table');
} catch (error) {
console.error('Error adding timestamps to notes_tags:', error);
}
},
async up(queryInterface, Sequelize) {
// Add created_at and updated_at columns to notes_tags table
try {
await queryInterface.addColumn('notes_tags', 'created_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
});
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('notes_tags', 'created_at');
await queryInterface.removeColumn('notes_tags', 'updated_at');
}
};
await queryInterface.addColumn('notes_tags', 'updated_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
});
console.log('Successfully added timestamps to notes_tags table');
} catch (error) {
console.error('Error adding timestamps to notes_tags:', error);
}
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('notes_tags', 'created_at');
await queryInterface.removeColumn('notes_tags', 'updated_at');
},
};

View file

@ -1,82 +1,83 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
// Create the projects_tags table if it doesn't exist
const tableExists = await queryInterface.showAllTables()
.then(tables => tables.includes('projects_tags'));
if (!tableExists) {
await queryInterface.createTable('projects_tags', {
project_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'projects',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
tag_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'tags',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
// Add composite primary key
await queryInterface.addConstraint('projects_tags', {
fields: ['project_id', 'tag_id'],
type: 'primary key',
name: 'projects_tags_pkey'
});
} else {
// Add timestamps if table exists but doesn't have them
try {
await queryInterface.addColumn('projects_tags', 'created_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
});
} catch (error) {
// Column might already exist
}
try {
await queryInterface.addColumn('projects_tags', 'updated_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
});
} catch (error) {
// Column might already exist
}
}
},
async up(queryInterface, Sequelize) {
// Create the projects_tags table if it doesn't exist
const tableExists = await queryInterface
.showAllTables()
.then((tables) => tables.includes('projects_tags'));
async down(queryInterface, Sequelize) {
// Remove timestamps or drop table if needed
try {
await queryInterface.removeColumn('projects_tags', 'created_at');
await queryInterface.removeColumn('projects_tags', 'updated_at');
} catch (error) {
// Columns might not exist
}
}
};
if (!tableExists) {
await queryInterface.createTable('projects_tags', {
project_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'projects',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
tag_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'tags',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
// Add composite primary key
await queryInterface.addConstraint('projects_tags', {
fields: ['project_id', 'tag_id'],
type: 'primary key',
name: 'projects_tags_pkey',
});
} else {
// Add timestamps if table exists but doesn't have them
try {
await queryInterface.addColumn('projects_tags', 'created_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
});
} catch (error) {
// Column might already exist
}
try {
await queryInterface.addColumn('projects_tags', 'updated_at', {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
});
} catch (error) {
// Column might already exist
}
}
},
async down(queryInterface, Sequelize) {
// Remove timestamps or drop table if needed
try {
await queryInterface.removeColumn('projects_tags', 'created_at');
await queryInterface.removeColumn('projects_tags', 'updated_at');
} catch (error) {
// Columns might not exist
}
},
};

View file

@ -1,36 +1,40 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Area = sequelize.define('Area', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.STRING,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'areas',
indexes: [
{
fields: ['user_id']
}
]
});
const Area = sequelize.define(
'Area',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.STRING,
allowNull: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
},
{
tableName: 'areas',
indexes: [
{
fields: ['user_id'],
},
],
}
);
return Area;
};
return Area;
};

View file

@ -1,77 +1,81 @@
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');
const CalendarToken = sequelize.define('CalendarToken', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id'
},
onDelete: 'CASCADE'
},
provider: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'google'
},
access_token: {
type: DataTypes.TEXT,
allowNull: false
},
refresh_token: {
type: DataTypes.TEXT,
allowNull: true
},
token_type: {
type: DataTypes.STRING,
defaultValue: 'Bearer'
},
expires_at: {
type: DataTypes.DATE,
allowNull: true
},
scope: {
type: DataTypes.TEXT,
allowNull: true
},
connected_email: {
type: DataTypes.STRING,
allowNull: true
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
}
}, {
tableName: 'calendar_tokens',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
const CalendarToken = sequelize.define(
'CalendarToken',
{
unique: true,
fields: ['user_id', 'provider']
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'Users',
key: 'id',
},
onDelete: 'CASCADE',
},
provider: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'google',
},
access_token: {
type: DataTypes.TEXT,
allowNull: false,
},
refresh_token: {
type: DataTypes.TEXT,
allowNull: true,
},
token_type: {
type: DataTypes.STRING,
defaultValue: 'Bearer',
},
expires_at: {
type: DataTypes.DATE,
allowNull: true,
},
scope: {
type: DataTypes.TEXT,
allowNull: true,
},
connected_email: {
type: DataTypes.STRING,
allowNull: true,
},
created_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
updated_at: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
},
{
tableName: 'calendar_tokens',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
unique: true,
fields: ['user_id', 'provider'],
},
],
}
]
});
);
// Associations
CalendarToken.associate = function(models) {
CalendarToken.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user'
});
CalendarToken.associate = function (models) {
CalendarToken.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'user',
});
};
module.exports = CalendarToken;
module.exports = CalendarToken;

View file

@ -1,42 +1,46 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const InboxItem = sequelize.define('InboxItem', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
content: {
type: DataTypes.STRING,
allowNull: false
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'added'
},
source: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'tududi'
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'inbox_items',
indexes: [
{
fields: ['user_id']
}
]
});
const InboxItem = sequelize.define(
'InboxItem',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
content: {
type: DataTypes.STRING,
allowNull: false,
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'added',
},
source: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'tududi',
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
},
{
tableName: 'inbox_items',
indexes: [
{
fields: ['user_id'],
},
],
}
);
return InboxItem;
};
return InboxItem;
};

View file

@ -5,35 +5,41 @@ const path = require('path');
let dbConfig;
if (process.env.NODE_ENV === 'test') {
// Use temporary file database for tests to allow external script access
const testDbPath = path.join(__dirname, '../db', 'test.sqlite3');
dbConfig = {
dialect: 'sqlite',
storage: testDbPath,
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
};
// Use temporary file database for tests to allow external script access
const testDbPath = path.join(__dirname, '../db', 'test.sqlite3');
dbConfig = {
dialect: 'sqlite',
storage: testDbPath,
logging: false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
},
};
} else {
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(__dirname, '../db', process.env.NODE_ENV === 'production' ? 'production.sqlite3' : 'development.sqlite3');
const dbPath = process.env.DATABASE_URL
? process.env.DATABASE_URL.replace('sqlite:///', '')
: path.join(
__dirname,
'../db',
process.env.NODE_ENV === 'production'
? 'production.sqlite3'
: 'development.sqlite3'
);
dbConfig = {
dialect: 'sqlite',
storage: dbPath,
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
}
};
dbConfig = {
dialect: 'sqlite',
storage: dbPath,
logging: process.env.NODE_ENV === 'development' ? console.log : false,
define: {
timestamps: true,
underscored: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
},
};
}
const sequelize = new Sequelize(dbConfig);
@ -80,23 +86,47 @@ Task.hasMany(TaskEvent, { foreignKey: 'task_id', as: 'TaskEvents' });
TaskEvent.belongsTo(Task, { foreignKey: 'task_id', as: 'Task' });
// Many-to-many associations
Task.belongsToMany(Tag, { through: 'tasks_tags', foreignKey: 'task_id', otherKey: 'tag_id' });
Tag.belongsToMany(Task, { through: 'tasks_tags', foreignKey: 'tag_id', otherKey: 'task_id' });
Task.belongsToMany(Tag, {
through: 'tasks_tags',
foreignKey: 'task_id',
otherKey: 'tag_id',
});
Tag.belongsToMany(Task, {
through: 'tasks_tags',
foreignKey: 'tag_id',
otherKey: 'task_id',
});
Note.belongsToMany(Tag, { through: 'notes_tags', foreignKey: 'note_id', otherKey: 'tag_id' });
Tag.belongsToMany(Note, { through: 'notes_tags', foreignKey: 'tag_id', otherKey: 'note_id' });
Note.belongsToMany(Tag, {
through: 'notes_tags',
foreignKey: 'note_id',
otherKey: 'tag_id',
});
Tag.belongsToMany(Note, {
through: 'notes_tags',
foreignKey: 'tag_id',
otherKey: 'note_id',
});
Project.belongsToMany(Tag, { through: 'projects_tags', foreignKey: 'project_id', otherKey: 'tag_id' });
Tag.belongsToMany(Project, { through: 'projects_tags', foreignKey: 'tag_id', otherKey: 'project_id' });
Project.belongsToMany(Tag, {
through: 'projects_tags',
foreignKey: 'project_id',
otherKey: 'tag_id',
});
Tag.belongsToMany(Project, {
through: 'projects_tags',
foreignKey: 'tag_id',
otherKey: 'project_id',
});
module.exports = {
sequelize,
User,
Area,
Project,
Task,
Tag,
Note,
InboxItem,
TaskEvent
};
sequelize,
User,
Area,
Project,
Task,
Tag,
Note,
InboxItem,
TaskEvent,
};

View file

@ -1,47 +1,51 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Note = sequelize.define('Note', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: true
},
content: {
type: DataTypes.TEXT,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id'
}
}
}, {
tableName: 'notes',
indexes: [
{
fields: ['user_id']
},
{
fields: ['project_id']
}
]
});
const Note = sequelize.define(
'Note',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
title: {
type: DataTypes.STRING,
allowNull: true,
},
content: {
type: DataTypes.TEXT,
allowNull: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id',
},
},
},
{
tableName: 'notes',
indexes: [
{
fields: ['user_id'],
},
{
fields: ['project_id'],
},
],
}
);
return Note;
};
return Note;
};

View file

@ -1,73 +1,77 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Project = sequelize.define('Project', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
pin_to_sidebar: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 2
}
},
due_date_at: {
type: DataTypes.DATE,
allowNull: true
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
area_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'areas',
key: 'id'
}
},
image_url: {
type: DataTypes.TEXT,
allowNull: true
}
}, {
tableName: 'projects',
indexes: [
{
fields: ['user_id']
},
{
fields: ['area_id']
}
]
});
const Project = sequelize.define(
'Project',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
pin_to_sidebar: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 2,
},
},
due_date_at: {
type: DataTypes.DATE,
allowNull: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
area_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'areas',
key: 'id',
},
},
image_url: {
type: DataTypes.TEXT,
allowNull: true,
},
},
{
tableName: 'projects',
indexes: [
{
fields: ['user_id'],
},
{
fields: ['area_id'],
},
],
}
);
return Project;
};
return Project;
};

View file

@ -1,32 +1,36 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Tag = sequelize.define('Tag', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
}
}, {
tableName: 'tags',
indexes: [
{
fields: ['user_id']
}
]
});
const Tag = sequelize.define(
'Tag',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
},
{
tableName: 'tags',
indexes: [
{
fields: ['user_id'],
},
],
}
);
return Tag;
};
return Tag;
};

View file

@ -1,219 +1,231 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const Task = sequelize.define('Task', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
uuid: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
defaultValue: DataTypes.UUIDV4
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
due_date: {
type: DataTypes.DATE,
allowNull: true
},
today: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
validate: {
min: 0,
max: 2
}
},
status: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
max: 4
}
},
note: {
type: DataTypes.TEXT,
allowNull: true
},
recurrence_type: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'none'
},
recurrence_interval: {
type: DataTypes.INTEGER,
allowNull: true
},
recurrence_end_date: {
type: DataTypes.DATE,
allowNull: true
},
last_generated_date: {
type: DataTypes.DATE,
allowNull: true
},
recurrence_weekday: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 6
}
},
recurrence_month_day: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: -1,
max: 31
}
},
recurrence_week_of_month: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 1,
max: 5
}
},
completion_based: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id'
}
},
recurring_parent_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id'
}
},
completed_at: {
type: DataTypes.DATE,
allowNull: true
}
}, {
tableName: 'tasks',
indexes: [
{
fields: ['user_id']
},
{
fields: ['project_id']
},
{
fields: ['recurrence_type']
},
{
fields: ['last_generated_date']
}
]
});
const Task = sequelize.define(
'Task',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
uuid: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
defaultValue: DataTypes.UUIDV4,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
due_date: {
type: DataTypes.DATE,
allowNull: true,
},
today: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
priority: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
validate: {
min: 0,
max: 2,
},
},
status: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
max: 4,
},
},
note: {
type: DataTypes.TEXT,
allowNull: true,
},
recurrence_type: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'none',
},
recurrence_interval: {
type: DataTypes.INTEGER,
allowNull: true,
},
recurrence_end_date: {
type: DataTypes.DATE,
allowNull: true,
},
last_generated_date: {
type: DataTypes.DATE,
allowNull: true,
},
recurrence_weekday: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 0,
max: 6,
},
},
recurrence_month_day: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: -1,
max: 31,
},
},
recurrence_week_of_month: {
type: DataTypes.INTEGER,
allowNull: true,
validate: {
min: 1,
max: 5,
},
},
completion_based: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
project_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'projects',
key: 'id',
},
},
recurring_parent_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id',
},
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
},
},
{
tableName: 'tasks',
indexes: [
{
fields: ['user_id'],
},
{
fields: ['project_id'],
},
{
fields: ['recurrence_type'],
},
{
fields: ['last_generated_date'],
},
],
}
);
// Define associations
Task.associate = function(models) {
// Self-referencing association for recurring tasks
Task.belongsTo(models.Task, {
as: 'RecurringParent',
foreignKey: 'recurring_parent_id'
});
Task.hasMany(models.Task, {
as: 'RecurringChildren',
foreignKey: 'recurring_parent_id'
});
};
// Define associations
Task.associate = function (models) {
// Self-referencing association for recurring tasks
Task.belongsTo(models.Task, {
as: 'RecurringParent',
foreignKey: 'recurring_parent_id',
});
// Define enum constants
Task.PRIORITY = {
LOW: 0,
MEDIUM: 1,
HIGH: 2
};
Task.STATUS = {
NOT_STARTED: 0,
IN_PROGRESS: 1,
DONE: 2,
ARCHIVED: 3,
WAITING: 4
};
Task.RECURRENCE_TYPE = {
NONE: 'none',
DAILY: 'daily',
WEEKLY: 'weekly',
MONTHLY: 'monthly',
MONTHLY_WEEKDAY: 'monthly_weekday',
MONTHLY_LAST_DAY: 'monthly_last_day'
};
// priority and status
const getPriorityName = (priorityValue) => {
const priorities = ['low', 'medium', 'high'];
return priorities[priorityValue] || 'low';
};
const getStatusName = (statusValue) => {
const statuses = ['not_started', 'in_progress', 'done', 'archived', 'waiting'];
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
Task.hasMany(models.Task, {
as: 'RecurringChildren',
foreignKey: 'recurring_parent_id',
});
};
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
};
// Attach utility functions to model
Task.getPriorityName = getPriorityName;
Task.getStatusName = getStatusName;
Task.getPriorityValue = getPriorityValue;
Task.getStatusValue = getStatusValue;
// Define enum constants
Task.PRIORITY = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
};
return Task;
};
Task.STATUS = {
NOT_STARTED: 0,
IN_PROGRESS: 1,
DONE: 2,
ARCHIVED: 3,
WAITING: 4,
};
Task.RECURRENCE_TYPE = {
NONE: 'none',
DAILY: 'daily',
WEEKLY: 'weekly',
MONTHLY: 'monthly',
MONTHLY_WEEKDAY: 'monthly_weekday',
MONTHLY_LAST_DAY: 'monthly_last_day',
};
// priority and status
const getPriorityName = (priorityValue) => {
const priorities = ['low', 'medium', 'high'];
return priorities[priorityValue] || 'low';
};
const getStatusName = (statusValue) => {
const statuses = [
'not_started',
'in_progress',
'done',
'archived',
'waiting',
];
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

@ -1,211 +1,282 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const TaskEvent = sequelize.define('TaskEvent', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
task_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'tasks',
key: 'id'
}
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id'
}
},
event_type: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isIn: [['created', 'status_changed', 'priority_changed', 'due_date_changed',
'project_changed', 'name_changed', 'description_changed', 'note_changed',
'completed', 'archived', 'deleted', 'restored', 'today_changed',
'tags_changed', 'recurrence_changed', 'recurrence_type_changed',
'completion_based_changed', 'recurrence_end_date_changed']]
}
},
old_value: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('old_value');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue('old_value', value ? JSON.stringify(value) : null);
}
},
new_value: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('new_value');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue('new_value', value ? JSON.stringify(value) : null);
}
},
field_name: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isIn: [['status', 'priority', 'due_date', 'project_id', 'name', 'description',
'note', 'today', 'tags', 'recurrence_type', 'recurrence_interval',
'recurrence_end_date', 'recurrence_weekday', 'recurrence_month_day',
'recurrence_week_of_month', 'completion_based']]
}
},
metadata: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('metadata');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue('metadata', value ? JSON.stringify(value) : null);
}
}
}, {
tableName: 'task_events',
timestamps: true,
createdAt: 'created_at',
updatedAt: false, // We don't need updated_at for events (they're immutable)
indexes: [
{
fields: ['task_id']
},
{
fields: ['user_id']
},
{
fields: ['event_type']
},
{
fields: ['created_at']
},
{
fields: ['task_id', 'event_type']
},
{
fields: ['task_id', 'created_at']
}
]
});
// Define associations
TaskEvent.associate = function(models) {
// TaskEvent belongs to Task
TaskEvent.belongsTo(models.Task, {
foreignKey: 'task_id',
as: 'Task'
});
// TaskEvent belongs to User
TaskEvent.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'User'
});
};
// Helper methods for common event types
TaskEvent.createStatusChangeEvent = async function(taskId, userId, oldStatus, newStatus, metadata = {}) {
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: 'status_changed',
field_name: 'status',
old_value: { status: oldStatus },
new_value: { status: newStatus },
metadata: metadata
});
};
TaskEvent.createTaskCreatedEvent = async function(taskId, userId, taskData, metadata = {}) {
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: 'created',
field_name: null,
old_value: null,
new_value: taskData,
metadata: metadata
});
};
TaskEvent.createFieldChangeEvent = async function(taskId, userId, fieldName, oldValue, newValue, metadata = {}) {
const eventType = fieldName === 'status' && newValue === 2 ? 'completed' :
fieldName === 'status' && newValue === 3 ? 'archived' :
`${fieldName}_changed`;
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: eventType,
field_name: fieldName,
old_value: { [fieldName]: oldValue },
new_value: { [fieldName]: newValue },
metadata: metadata
});
};
// Query helpers
TaskEvent.getTaskTimeline = async function(taskId) {
return await TaskEvent.findAll({
where: { task_id: taskId },
order: [['created_at', 'ASC']],
include: [{
model: sequelize.models.User,
as: 'User',
attributes: ['id', 'name', 'email']
}]
});
};
TaskEvent.getCompletionTime = async function(taskId) {
const events = await TaskEvent.findAll({
where: {
task_id: taskId,
event_type: ['status_changed', 'created', 'completed']
},
order: [['created_at', 'ASC']]
});
if (events.length === 0) return null;
const startEvent = events.find(e =>
e.event_type === 'created' ||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
const TaskEvent = sequelize.define(
'TaskEvent',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
task_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'tasks',
key: 'id',
},
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
event_type: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isIn: [
[
'created',
'status_changed',
'priority_changed',
'due_date_changed',
'project_changed',
'name_changed',
'description_changed',
'note_changed',
'completed',
'archived',
'deleted',
'restored',
'today_changed',
'tags_changed',
'recurrence_changed',
'recurrence_type_changed',
'completion_based_changed',
'recurrence_end_date_changed',
],
],
},
},
old_value: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('old_value');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue(
'old_value',
value ? JSON.stringify(value) : null
);
},
},
new_value: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('new_value');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue(
'new_value',
value ? JSON.stringify(value) : null
);
},
},
field_name: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isIn: [
[
'status',
'priority',
'due_date',
'project_id',
'name',
'description',
'note',
'today',
'tags',
'recurrence_type',
'recurrence_interval',
'recurrence_end_date',
'recurrence_weekday',
'recurrence_month_day',
'recurrence_week_of_month',
'completion_based',
],
],
},
},
metadata: {
type: DataTypes.TEXT,
allowNull: true,
get() {
const rawValue = this.getDataValue('metadata');
return rawValue ? JSON.parse(rawValue) : null;
},
set(value) {
this.setDataValue(
'metadata',
value ? JSON.stringify(value) : null
);
},
},
},
{
tableName: 'task_events',
timestamps: true,
createdAt: 'created_at',
updatedAt: false, // We don't need updated_at for events (they're immutable)
indexes: [
{
fields: ['task_id'],
},
{
fields: ['user_id'],
},
{
fields: ['event_type'],
},
{
fields: ['created_at'],
},
{
fields: ['task_id', 'event_type'],
},
{
fields: ['task_id', 'created_at'],
},
],
}
);
const completedEvent = events.find(e =>
e.event_type === 'completed' ||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
);
// Define associations
TaskEvent.associate = function (models) {
// TaskEvent belongs to Task
TaskEvent.belongsTo(models.Task, {
foreignKey: 'task_id',
as: 'Task',
});
if (!startEvent || !completedEvent) return null;
const startTime = new Date(startEvent.created_at);
const endTime = new Date(completedEvent.created_at);
return {
started_at: startTime,
completed_at: endTime,
duration_ms: endTime - startTime,
duration_hours: (endTime - startTime) / (1000 * 60 * 60)
// TaskEvent belongs to User
TaskEvent.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'User',
});
};
};
return TaskEvent;
};
// Helper methods for common event types
TaskEvent.createStatusChangeEvent = async function (
taskId,
userId,
oldStatus,
newStatus,
metadata = {}
) {
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: 'status_changed',
field_name: 'status',
old_value: { status: oldStatus },
new_value: { status: newStatus },
metadata: metadata,
});
};
TaskEvent.createTaskCreatedEvent = async function (
taskId,
userId,
taskData,
metadata = {}
) {
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: 'created',
field_name: null,
old_value: null,
new_value: taskData,
metadata: metadata,
});
};
TaskEvent.createFieldChangeEvent = async function (
taskId,
userId,
fieldName,
oldValue,
newValue,
metadata = {}
) {
const eventType =
fieldName === 'status' && newValue === 2
? 'completed'
: fieldName === 'status' && newValue === 3
? 'archived'
: `${fieldName}_changed`;
return await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: eventType,
field_name: fieldName,
old_value: { [fieldName]: oldValue },
new_value: { [fieldName]: newValue },
metadata: metadata,
});
};
// Query helpers
TaskEvent.getTaskTimeline = async function (taskId) {
return await TaskEvent.findAll({
where: { task_id: taskId },
order: [['created_at', 'ASC']],
include: [
{
model: sequelize.models.User,
as: 'User',
attributes: ['id', 'name', 'email'],
},
],
});
};
TaskEvent.getCompletionTime = async function (taskId) {
const events = await TaskEvent.findAll({
where: {
task_id: taskId,
event_type: ['status_changed', 'created', 'completed'],
},
order: [['created_at', 'ASC']],
});
if (events.length === 0) return null;
const startEvent = events.find(
(e) =>
e.event_type === 'created' ||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
);
const completedEvent = events.find(
(e) =>
e.event_type === 'completed' ||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
);
if (!startEvent || !completedEvent) return null;
const startTime = new Date(startEvent.created_at);
const endTime = new Date(completedEvent.created_at);
return {
started_at: startTime,
completed_at: endTime,
duration_ms: endTime - startTime,
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
};
};
return TaskEvent;
};

View file

@ -2,135 +2,153 @@ const { DataTypes } = require('sequelize');
const bcrypt = require('bcrypt');
module.exports = (sequelize) => {
const User = sequelize.define('User', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: {
type: DataTypes.STRING,
allowNull: true
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true
}
},
password: {
type: DataTypes.VIRTUAL,
allowNull: true
},
password_digest: {
type: DataTypes.STRING,
allowNull: false,
field: 'password_digest'
},
appearance: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'light',
validate: {
isIn: [['light', 'dark']]
}
},
language: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'en'
},
timezone: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'UTC'
},
avatar_image: {
type: DataTypes.STRING,
allowNull: true
},
telegram_bot_token: {
type: DataTypes.STRING,
allowNull: true
},
telegram_chat_id: {
type: DataTypes.STRING,
allowNull: true
},
task_summary_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
task_summary_frequency: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'daily',
validate: {
isIn: [['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h']]
}
},
task_summary_last_run: {
type: DataTypes.DATE,
allowNull: true
},
task_summary_next_run: {
type: DataTypes.DATE,
allowNull: true
},
task_intelligence_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
},
auto_suggest_next_actions_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
pomodoro_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
},
today_settings: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: {
showMetrics: false,
showProductivity: false,
showIntelligence: false,
showDueToday: true,
showCompleted: true,
showProgressBar: true,
showDailyQuote: true
}
}
}, {
tableName: 'users',
hooks: {
beforeValidate: async (user) => {
if (user.password) {
user.password_digest = await bcrypt.hash(user.password, 10);
const User = sequelize.define(
'User',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
name: {
type: DataTypes.STRING,
allowNull: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
},
password: {
type: DataTypes.VIRTUAL,
allowNull: true,
},
password_digest: {
type: DataTypes.STRING,
allowNull: false,
field: 'password_digest',
},
appearance: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'light',
validate: {
isIn: [['light', 'dark']],
},
},
language: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'en',
},
timezone: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'UTC',
},
avatar_image: {
type: DataTypes.STRING,
allowNull: true,
},
telegram_bot_token: {
type: DataTypes.STRING,
allowNull: true,
},
telegram_chat_id: {
type: DataTypes.STRING,
allowNull: true,
},
task_summary_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
task_summary_frequency: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'daily',
validate: {
isIn: [
[
'daily',
'weekdays',
'weekly',
'1h',
'2h',
'4h',
'8h',
'12h',
],
],
},
},
task_summary_last_run: {
type: DataTypes.DATE,
allowNull: true,
},
task_summary_next_run: {
type: DataTypes.DATE,
allowNull: true,
},
task_intelligence_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
auto_suggest_next_actions_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
pomodoro_enabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
today_settings: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: {
showMetrics: false,
showProductivity: false,
showIntelligence: false,
showDueToday: true,
showCompleted: true,
showProgressBar: true,
showDailyQuote: true,
},
},
},
{
tableName: 'users',
hooks: {
beforeValidate: async (user) => {
if (user.password) {
user.password_digest = await bcrypt.hash(
user.password,
10
);
}
},
},
}
}
}
});
);
// password operations
const hashPassword = async (password) => {
return await bcrypt.hash(password, 10);
};
// password operations
const hashPassword = async (password) => {
return await bcrypt.hash(password, 10);
};
const checkPassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
const checkPassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
// Attach utility functions to model
User.hashPassword = hashPassword;
User.checkPassword = checkPassword;
// Attach utility functions to model
User.hashPassword = hashPassword;
User.checkPassword = checkPassword;
return User;
};
return User;
};

19144
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,65 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "cross-env NODE_ENV=test jest",
"test:watch": "cross-env NODE_ENV=test jest --watch",
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
"test:telegram-duplicates": "node scripts/test-telegram-duplicates.js",
"db:init": "node scripts/db-init.js",
"db:sync": "node scripts/db-sync.js",
"db:migrate": "node scripts/db-migrate.js",
"db:reset": "node scripts/db-reset.js",
"db:status": "node scripts/db-status.js",
"user:create": "node scripts/user-create.js",
"migration:create": "node scripts/migration-create.js",
"migration:run": "npx sequelize-cli db:migrate",
"migration:undo": "npx sequelize-cli db:migrate:undo",
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
"migration:status": "npx sequelize-cli db:migrate:status",
"seed:dev": "node scripts/seed-dev-data.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "^6.0.0",
"cheerio": "^1.1.0",
"compression": "^1.8.0",
"connect-session-sequelize": "^7.1.7",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^4.18.2",
"express-session": "^1.18.1",
"googleapis": "^144.0.0",
"helmet": "^8.1.0",
"js-yaml": "^4.1.0",
"moment-timezone": "^0.6.0",
"morgan": "^1.10.0",
"multer": "^2.0.1",
"node-cron": "^4.1.0",
"recharts": "^2.15.4",
"sequelize": "^6.37.7",
"sqlite3": "^5.1.7",
"uuid": "^11.1.0"
},
"devDependencies": {
"cross-env": "^7.0.3",
"jest": "^30.0.0",
"nodemon": "^3.0.1",
"sequelize-cli": "^6.6.2",
"supertest": "^7.1.1"
}
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "cross-env NODE_ENV=test jest",
"test:watch": "cross-env NODE_ENV=test jest --watch",
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
"test:telegram-duplicates": "node scripts/test-telegram-duplicates.js",
"db:init": "node scripts/db-init.js",
"db:sync": "node scripts/db-sync.js",
"db:migrate": "node scripts/db-migrate.js",
"db:reset": "node scripts/db-reset.js",
"db:status": "node scripts/db-status.js",
"user:create": "node scripts/user-create.js",
"migration:create": "node scripts/migration-create.js",
"migration:run": "npx sequelize-cli db:migrate",
"migration:undo": "npx sequelize-cli db:migrate:undo",
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
"migration:status": "npx sequelize-cli db:migrate:status",
"seed:dev": "node scripts/seed-dev-data.js",
"lint": "eslint .",
"lint-fix": "eslint . --fix",
"format": "prettier --write ."
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcrypt": "~6.0.0",
"compression": "~1.8.0",
"connect-session-sequelize": "~7.1.7",
"cors": "~2.8.5",
"dotenv": "~16.5.0",
"eslint": "^8.0.0",
"express": "~4.18.2",
"express-session": "~1.18.1",
"googleapis": "~144.0.0",
"helmet": "~8.1.0",
"js-yaml": "~4.1.0",
"moment-timezone": "~0.6.0",
"morgan": "~1.10.0",
"multer": "~2.0.1",
"node-cron": "~4.1.0",
"recharts": "~2.15.4",
"sequelize": "~6.37.7",
"sqlite3": "~5.1.7",
"uuid": "~11.1.0"
},
"devDependencies": {
"cross-env": "~7.0.3",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-prettier": "^5.5.1",
"jest": "~30.0.0",
"nodemon": "~3.0.1",
"prettier": "~3.6.2",
"sequelize-cli": "~6.6.2",
"supertest": "~7.1.1"
}
}

View file

@ -4,127 +4,135 @@ const router = express.Router();
// GET /api/areas
router.get('/areas', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const areas = await Area.findAll({
where: { user_id: req.session.userId },
order: [['name', 'ASC']],
});
res.json(areas);
} catch (error) {
console.error('Error fetching areas:', error);
res.status(500).json({ error: 'Internal server error' });
}
const areas = await Area.findAll({
where: { user_id: req.session.userId },
order: [['name', 'ASC']]
});
res.json(areas);
} catch (error) {
console.error('Error fetching areas:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/areas/:id
router.get('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!area) {
return res.status(404).json({
error: "Area not found or doesn't belong to the current user.",
});
}
res.json(area);
} catch (error) {
console.error('Error fetching area:', error);
res.status(500).json({ error: 'Internal server error' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: "Area not found or doesn't belong to the current user." });
}
res.json(area);
} catch (error) {
console.error('Error fetching area:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/areas
router.post('/areas', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Area name is required.' });
}
const area = await Area.create({
name: name.trim(),
description: description || '',
user_id: req.session.userId,
});
res.status(201).json(area);
} catch (error) {
console.error('Error creating area:', error);
res.status(400).json({
error: 'There was a problem creating the area.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const { name, description } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Area name is required.' });
}
const area = await Area.create({
name: name.trim(),
description: description || '',
user_id: req.session.userId
});
res.status(201).json(area);
} catch (error) {
console.error('Error creating area:', error);
res.status(400).json({
error: 'There was a problem creating the area.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/areas/:id
router.patch('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
const { name, description } = req.body;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
await area.update(updateData);
res.json(area);
} catch (error) {
console.error('Error updating area:', error);
res.status(400).json({
error: 'There was a problem updating the area.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
const { name, description } = req.body;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
await area.update(updateData);
res.json(area);
} catch (error) {
console.error('Error updating area:', error);
res.status(400).json({
error: 'There was a problem updating the area.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/areas/:id
router.delete('/areas/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
await area.destroy();
res.status(204).send();
} catch (error) {
console.error('Error deleting area:', error);
res.status(400).json({
error: 'There was a problem deleting the area.',
});
}
const area = await Area.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!area) {
return res.status(404).json({ error: 'Area not found.' });
}
await area.destroy();
res.status(204).send();
} catch (error) {
console.error('Error deleting area:', error);
res.status(400).json({ error: 'There was a problem deleting the area.' });
}
});
module.exports = router;
module.exports = router;

View file

@ -4,75 +4,78 @@ const router = express.Router();
// Get current user
router.get('/current_user', async (req, res) => {
try {
if (req.session && req.session.userId) {
const user = await User.findByPk(req.session.userId);
if (user) {
return res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone
}
});
}
try {
if (req.session && req.session.userId) {
const user = await User.findByPk(req.session.userId);
if (user) {
return res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone,
},
});
}
}
res.json({ user: null });
} catch (error) {
console.error('Error fetching current user:', error);
res.status(500).json({ error: 'Internal server error' });
}
res.json({ user: null });
} catch (error) {
console.error('Error fetching current user:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Invalid login parameters.' });
if (!email || !password) {
return res.status(400).json({ error: 'Invalid login parameters.' });
}
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
const isValidPassword = await User.checkPassword(
password,
user.password_digest
);
if (!isValidPassword) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
req.session.userId = user.id;
res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
const isValidPassword = await User.checkPassword(password, user.password_digest);
if (!isValidPassword) {
return res.status(401).json({ errors: ['Invalid credentials'] });
}
req.session.userId = user.id;
res.json({
user: {
id: user.id,
email: user.email,
language: user.language,
appearance: user.appearance,
timezone: user.timezone
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout
router.get('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Could not log out' });
}
res.json({ message: 'Logged out successfully' });
});
req.session.destroy((err) => {
if (err) {
console.error('Logout error:', err);
return res.status(500).json({ error: 'Could not log out' });
}
res.json({ message: 'Logged out successfully' });
});
});
module.exports = router;
module.exports = router;

View file

@ -8,132 +8,149 @@ const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
// OAuth2 client setup
const getOAuth2Client = () => {
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3002/api/calendar/oauth/callback'
);
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI ||
'http://localhost:3002/api/calendar/oauth/callback'
);
};
// GET /api/calendar/auth - Start OAuth flow (Demo mode)
router.get('/auth', requireAuth, (req, res) => {
try {
// Check if Google credentials are configured
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
// Demo mode - simulate successful connection
console.log('Demo mode: Simulating Google Calendar connection for user:', req.currentUser.id);
// Simulate the callback redirect with success
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:8080';
return res.json({
authUrl: `${frontendUrl}/calendar?demo=true&connected=true`,
demo: true,
message: 'Demo mode: Google Calendar integration simulated'
});
try {
// Check if Google credentials are configured
if (
!process.env.GOOGLE_CLIENT_ID ||
!process.env.GOOGLE_CLIENT_SECRET
) {
// Demo mode - simulate successful connection
console.log(
'Demo mode: Simulating Google Calendar connection for user:',
req.currentUser.id
);
// Simulate the callback redirect with success
const frontendUrl =
process.env.FRONTEND_URL || 'http://localhost:8080';
return res.json({
authUrl: `${frontendUrl}/calendar?demo=true&connected=true`,
demo: true,
message: 'Demo mode: Google Calendar integration simulated',
});
}
// Production mode with real Google OAuth
const oauth2Client = getOAuth2Client();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
state: JSON.stringify({ userId: req.currentUser.id }),
});
res.json({ authUrl });
} catch (error) {
console.error('Error generating auth URL:', error);
res.status(500).json({ error: 'Failed to generate authorization URL' });
}
// Production mode with real Google OAuth
const oauth2Client = getOAuth2Client();
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
state: JSON.stringify({ userId: req.currentUser.id })
});
res.json({ authUrl });
} catch (error) {
console.error('Error generating auth URL:', error);
res.status(500).json({ error: 'Failed to generate authorization URL' });
}
});
// GET /api/calendar/oauth/callback - Handle OAuth callback
router.get('/oauth/callback', async (req, res) => {
try {
const { code, state } = req.query;
if (!code) {
return res.status(400).json({ error: 'Authorization code not provided' });
try {
const { code, state } = req.query;
if (!code) {
return res
.status(400)
.json({ error: 'Authorization code not provided' });
}
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
// Parse state to get user ID
const { userId } = JSON.parse(state);
// Here you would typically save the tokens to the database
// For now, we'll just return them (in production, store securely)
console.log('Google Calendar tokens received for user:', userId);
console.log('Tokens:', tokens);
// TODO: Save tokens to database associated with user
// await saveGoogleTokensForUser(userId, tokens);
// Redirect to frontend with success
res.redirect(
`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?connected=true`
);
} catch (error) {
console.error('Error handling OAuth callback:', error);
res.redirect(
`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?error=auth_failed`
);
}
const oauth2Client = getOAuth2Client();
const { tokens } = await oauth2Client.getToken(code);
// Parse state to get user ID
const { userId } = JSON.parse(state);
// Here you would typically save the tokens to the database
// For now, we'll just return them (in production, store securely)
console.log('Google Calendar tokens received for user:', userId);
console.log('Tokens:', tokens);
// TODO: Save tokens to database associated with user
// await saveGoogleTokensForUser(userId, tokens);
// Redirect to frontend with success
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?connected=true`);
} catch (error) {
console.error('Error handling OAuth callback:', error);
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?error=auth_failed`);
}
});
// GET /api/calendar/status - Check connection status
router.get('/status', requireAuth, async (req, res) => {
try {
// Check if we're in demo mode or have real Google integration
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
// Demo mode - check if user has been "connected" in this session
// For demo purposes, we'll simulate connection status
res.json({
connected: false, // Will be set to true after demo connection
email: null,
demo: true
});
return;
}
try {
// Check if we're in demo mode or have real Google integration
if (
!process.env.GOOGLE_CLIENT_ID ||
!process.env.GOOGLE_CLIENT_SECRET
) {
// Demo mode - check if user has been "connected" in this session
// For demo purposes, we'll simulate connection status
res.json({
connected: false, // Will be set to true after demo connection
email: null,
demo: true,
});
return;
}
// TODO: Check if user has valid Google Calendar tokens in database
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
res.json({
connected: false, // Change to true when tokens exist and are valid
email: null // Return connected Google account email when available
});
} catch (error) {
console.error('Error checking calendar status:', error);
res.status(500).json({ error: 'Failed to check calendar status' });
}
// TODO: Check if user has valid Google Calendar tokens in database
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
res.json({
connected: false, // Change to true when tokens exist and are valid
email: null, // Return connected Google account email when available
});
} catch (error) {
console.error('Error checking calendar status:', error);
res.status(500).json({ error: 'Failed to check calendar status' });
}
});
// GET /api/calendar/events - Get events from Google Calendar
router.get('/events', requireAuth, async (req, res) => {
try {
const { start, end } = req.query;
// TODO: Get tokens from database
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
// if (!tokens) {
// return res.status(401).json({ error: 'Google Calendar not connected' });
// }
try {
const { start, end } = req.query;
// For now, return sample data
const sampleEvents = [
{
id: 'google-1',
title: 'Google Calendar Event',
start: new Date().toISOString(),
end: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
type: 'google',
color: '#ea4335'
}
];
// TODO: Get tokens from database
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
// if (!tokens) {
// return res.status(401).json({ error: 'Google Calendar not connected' });
// }
res.json({ events: sampleEvents });
// For now, return sample data
const sampleEvents = [
{
id: 'google-1',
title: 'Google Calendar Event',
start: new Date().toISOString(),
end: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
type: 'google',
color: '#ea4335',
},
];
// TODO: Implement actual Google Calendar API call
/*
res.json({ events: sampleEvents });
// TODO: Implement actual Google Calendar API call
/*
const oauth2Client = getOAuth2Client();
oauth2Client.setCredentials(tokens);
@ -161,23 +178,23 @@ router.get('/events', requireAuth, async (req, res) => {
res.json({ events });
*/
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).json({ error: 'Failed to fetch calendar events' });
}
} catch (error) {
console.error('Error fetching calendar events:', error);
res.status(500).json({ error: 'Failed to fetch calendar events' });
}
});
// POST /api/calendar/disconnect - Disconnect Google Calendar
router.post('/disconnect', requireAuth, async (req, res) => {
try {
// TODO: Remove tokens from database
// await removeGoogleTokensForUser(req.currentUser.id);
res.json({ success: true, message: 'Google Calendar disconnected' });
} catch (error) {
console.error('Error disconnecting calendar:', error);
res.status(500).json({ error: 'Failed to disconnect calendar' });
}
try {
// TODO: Remove tokens from database
// await removeGoogleTokensForUser(req.currentUser.id);
res.json({ success: true, message: 'Google Calendar disconnected' });
} catch (error) {
console.error('Error disconnecting calendar:', error);
res.status(500).json({ error: 'Failed to disconnect calendar' });
}
});
module.exports = router;
module.exports = router;

View file

@ -4,154 +4,162 @@ const router = express.Router();
// GET /api/inbox
router.get('/inbox', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const items = await InboxItem.findAll({
where: {
user_id: req.session.userId,
status: 'added',
},
order: [['created_at', 'DESC']],
});
res.json(items);
} catch (error) {
console.error('Error fetching inbox items:', error);
res.status(500).json({ error: 'Internal server error' });
}
const items = await InboxItem.findAll({
where: {
user_id: req.session.userId,
status: 'added'
},
order: [['created_at', 'DESC']]
});
res.json(items);
} catch (error) {
console.error('Error fetching inbox items:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/inbox
router.post('/inbox', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { content, source } = req.body;
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Content is required' });
}
const item = await InboxItem.create({
content: content.trim(),
source: source || 'tududi',
user_id: req.session.userId,
});
res.status(201).json(item);
} catch (error) {
console.error('Error creating inbox item:', error);
res.status(400).json({
error: 'There was a problem creating the inbox item.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const { content, source } = req.body;
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Content is required' });
}
const item = await InboxItem.create({
content: content.trim(),
source: source || 'tududi',
user_id: req.session.userId
});
res.status(201).json(item);
} catch (error) {
console.error('Error creating inbox item:', error);
res.status(400).json({
error: 'There was a problem creating the inbox item.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// GET /api/inbox/:id
router.get('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
res.json(item);
} catch (error) {
console.error('Error fetching inbox item:', error);
res.status(500).json({ error: 'Internal server error' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
res.json(item);
} catch (error) {
console.error('Error fetching inbox item:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/inbox/:id
router.patch('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
const { content, status } = req.body;
const updateData = {};
if (content !== undefined) updateData.content = content;
if (status !== undefined) updateData.status = status;
await item.update(updateData);
res.json(item);
} catch (error) {
console.error('Error updating inbox item:', error);
res.status(400).json({
error: 'There was a problem updating the inbox item.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
const { content, status } = req.body;
const updateData = {};
if (content !== undefined) updateData.content = content;
if (status !== undefined) updateData.status = status;
await item.update(updateData);
res.json(item);
} catch (error) {
console.error('Error updating inbox item:', error);
res.status(400).json({
error: 'There was a problem updating the inbox item.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/inbox/:id
router.delete('/inbox/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
// Mark as deleted instead of actual deletion
await item.update({ status: 'deleted' });
res.json({ message: 'Inbox item successfully deleted' });
} catch (error) {
console.error('Error deleting inbox item:', error);
res.status(400).json({
error: 'There was a problem deleting the inbox item.',
});
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
// Mark as deleted instead of actual deletion
await item.update({ status: 'deleted' });
res.json({ message: 'Inbox item successfully deleted' });
} catch (error) {
console.error('Error deleting inbox item:', error);
res.status(400).json({ error: 'There was a problem deleting the inbox item.' });
}
});
// PATCH /api/inbox/:id/process
router.patch('/inbox/:id/process', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
await item.update({ status: 'processed' });
res.json(item);
} catch (error) {
console.error('Error processing inbox item:', error);
res.status(400).json({
error: 'There was a problem processing the inbox item.',
});
}
const item = await InboxItem.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!item) {
return res.status(404).json({ error: 'Inbox item not found.' });
}
await item.update({ status: 'processed' });
res.json(item);
} catch (error) {
console.error('Error processing inbox item:', error);
res.status(400).json({ error: 'There was a problem processing the inbox item.' });
}
});
module.exports = router;
module.exports = router;

View file

@ -5,238 +5,246 @@ const router = express.Router();
// Helper function to update note tags
async function updateNoteTags(note, tagsArray, userId) {
if (!tagsArray || tagsArray.length === 0) {
await note.setTags([]);
return;
}
if (!tagsArray || tagsArray.length === 0) {
await note.setTags([]);
return;
}
try {
const tagNames = tagsArray.filter((name, index, arr) => arr.indexOf(name) === index); // unique
const tags = await Promise.all(
tagNames.map(async (name) => {
const [tag] = await Tag.findOrCreate({
where: { name, user_id: userId },
defaults: { name, user_id: userId }
});
return tag;
})
);
await note.setTags(tags);
} catch (error) {
console.error('Failed to update tags:', error.message);
}
try {
const tagNames = tagsArray.filter(
(name, index, arr) => arr.indexOf(name) === index
); // unique
const tags = await Promise.all(
tagNames.map(async (name) => {
const [tag] = await Tag.findOrCreate({
where: { name, user_id: userId },
defaults: { name, user_id: userId },
});
return tag;
})
);
await note.setTags(tags);
} catch (error) {
console.error('Failed to update tags:', error.message);
}
}
// GET /api/notes
router.get('/notes', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const orderBy = req.query.order_by || 'title:asc';
const [orderColumn, orderDirection] = orderBy.split(':');
let whereClause = { user_id: req.session.userId };
let includeClause = [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] },
];
// Filter by tag
if (req.query.tag) {
includeClause[0].where = { name: req.query.tag };
includeClause[0].required = true;
}
const notes = await Note.findAll({
where: whereClause,
include: includeClause,
order: [[orderColumn, orderDirection.toUpperCase()]],
distinct: true,
});
res.json(notes);
} catch (error) {
console.error('Error fetching notes:', error);
res.status(500).json({ error: 'Internal server error' });
}
const orderBy = req.query.order_by || 'title:asc';
const [orderColumn, orderDirection] = orderBy.split(':');
let whereClause = { user_id: req.session.userId };
let includeClause = [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
];
// Filter by tag
if (req.query.tag) {
includeClause[0].where = { name: req.query.tag };
includeClause[0].required = true;
}
const notes = await Note.findAll({
where: whereClause,
include: includeClause,
order: [[orderColumn, orderDirection.toUpperCase()]],
distinct: true
});
res.json(notes);
} catch (error) {
console.error('Error fetching notes:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/note/:id
router.get('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] },
],
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
res.json(note);
} catch (error) {
console.error('Error fetching note:', error);
res.status(500).json({ error: 'Internal server error' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
res.json(note);
} catch (error) {
console.error('Error fetching note:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/note
router.post('/note', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { title, content, project_id, tags } = req.body;
const noteAttributes = {
title,
content,
user_id: req.session.userId,
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId },
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
noteAttributes.project_id = project_id;
}
const note = await Note.create(noteAttributes);
// Handle tags - can be array of strings or array of objects with name property
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every((t) => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every((t) => typeof t === 'object' && t.name)) {
tagNames = tags.map((t) => t.name);
}
}
await updateNoteTags(note, tagNames, req.session.userId);
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] },
],
});
res.status(201).json(noteWithAssociations);
} catch (error) {
console.error('Error creating note:', error);
res.status(400).json({
error: 'There was a problem creating the note.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const { title, content, project_id, tags } = req.body;
const noteAttributes = {
title,
content,
user_id: req.session.userId
};
// Handle project assignment
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId }
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
noteAttributes.project_id = project_id;
}
const note = await Note.create(noteAttributes);
// Handle tags - can be array of strings or array of objects with name property
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every(t => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every(t => typeof t === 'object' && t.name)) {
tagNames = tags.map(t => t.name);
}
}
await updateNoteTags(note, tagNames, req.session.userId);
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
res.status(201).json(noteWithAssociations);
} catch (error) {
console.error('Error creating note:', error);
res.status(400).json({
error: 'There was a problem creating the note.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/note/:id
router.patch('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
const { title, content, project_id, tags } = req.body;
const updateData = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
// Handle project assignment
if (project_id !== undefined) {
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId }
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
updateData.project_id = project_id;
} else {
updateData.project_id = null;
}
}
await note.update(updateData);
const { title, content, project_id, tags } = req.body;
// Handle tags if provided
if (tags !== undefined) {
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every(t => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every(t => typeof t === 'object' && t.name)) {
tagNames = tags.map(t => t.name);
const updateData = {};
if (title !== undefined) updateData.title = title;
if (content !== undefined) updateData.content = content;
// Handle project assignment
if (project_id !== undefined) {
if (project_id && project_id.toString().trim()) {
const project = await Project.findOne({
where: { id: project_id, user_id: req.session.userId },
});
if (!project) {
return res.status(400).json({ error: 'Invalid project.' });
}
updateData.project_id = project_id;
} else {
updateData.project_id = null;
}
}
}
await updateNoteTags(note, tagNames, req.session.userId);
await note.update(updateData);
// Handle tags if provided
if (tags !== undefined) {
let tagNames = [];
if (Array.isArray(tags)) {
if (tags.every((t) => typeof t === 'string')) {
tagNames = tags;
} else if (tags.every((t) => typeof t === 'object' && t.name)) {
tagNames = tags.map((t) => t.name);
}
}
await updateNoteTags(note, tagNames, req.session.userId);
}
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] },
],
});
res.json(noteWithAssociations);
} catch (error) {
console.error('Error updating note:', error);
res.status(400).json({
error: 'There was a problem updating the note.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
// Reload note with associations
const noteWithAssociations = await Note.findByPk(note.id, {
include: [
{ model: Tag, through: { attributes: [] } },
{ model: Project, required: false, attributes: ['id', 'name'] }
]
});
res.json(noteWithAssociations);
} catch (error) {
console.error('Error updating note:', error);
res.status(400).json({
error: 'There was a problem updating the note.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/note/:id
router.delete('/note/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
await note.destroy();
res.json({ message: 'Note deleted successfully.' });
} catch (error) {
console.error('Error deleting note:', error);
res.status(400).json({
error: 'There was a problem deleting the note.',
});
}
const note = await Note.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!note) {
return res.status(404).json({ error: 'Note not found.' });
}
await note.destroy();
res.json({ message: 'Note deleted successfully.' });
} catch (error) {
console.error('Error deleting note:', error);
res.status(400).json({ error: 'There was a problem deleting the note.' });
}
});
module.exports = router;
module.exports = router;

View file

@ -8,376 +8,422 @@ const router = express.Router();
// Helper function to safely format dates
const formatDate = (date) => {
if (!date) return null;
try {
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) return null;
return dateObj.toISOString();
} catch (error) {
return null;
}
if (!date) return null;
try {
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) return null;
return dateObj.toISOString();
} catch (error) {
return null;
}
};
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../uploads/projects');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, 'project-' + uniqueSuffix + path.extname(file.originalname));
}
destination: function (req, file, cb) {
const uploadDir = path.join(__dirname, '../uploads/projects');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, 'project-' + uniqueSuffix + path.extname(file.originalname));
},
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed!'));
}
}
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(
path.extname(file.originalname).toLowerCase()
);
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed!'));
}
},
});
// Helper function to update project tags
async function updateProjectTags(project, tagsData, userId) {
if (!tagsData) return;
if (!tagsData) return;
const tagNames = tagsData
.map(tag => tag.name)
.filter(name => name && name.trim())
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
const tagNames = tagsData
.map((tag) => tag.name)
.filter((name) => name && name.trim())
.filter((name, index, arr) => arr.indexOf(name) === index); // unique
if (tagNames.length === 0) {
await project.setTags([]);
return;
}
if (tagNames.length === 0) {
await project.setTags([]);
return;
}
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: tagNames }
});
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: tagNames },
});
// Create new tags
const existingTagNames = existingTags.map(tag => tag.name);
const newTagNames = tagNames.filter(name => !existingTagNames.includes(name));
const createdTags = await Promise.all(
newTagNames.map(name => Tag.create({ name, user_id: userId }))
);
// Create new tags
const existingTagNames = existingTags.map((tag) => tag.name);
const newTagNames = tagNames.filter(
(name) => !existingTagNames.includes(name)
);
// Set all tags to project
const allTags = [...existingTags, ...createdTags];
await project.setTags(allTags);
const createdTags = await Promise.all(
newTagNames.map((name) => Tag.create({ name, user_id: userId }))
);
// Set all tags to project
const allTags = [...existingTags, ...createdTags];
await project.setTags(allTags);
}
// POST /api/upload/project-image
router.post('/upload/project-image', upload.single('image'), (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
}
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
}
// Return the relative URL that can be accessed from the frontend
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
res.json({ imageUrl });
} catch (error) {
console.error('Error uploading image:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
// Return the relative URL that can be accessed from the frontend
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
res.json({ imageUrl });
} catch (error) {
console.error('Error uploading image:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
});
// GET /api/projects
router.get('/projects', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { active, pin_to_sidebar, area_id } = req.query;
let whereClause = { user_id: req.session.userId };
// Filter by active status
if (active === 'true') {
whereClause.active = true;
} else if (active === 'false') {
whereClause.active = false;
}
// Filter by pinned status
if (pin_to_sidebar === 'true') {
whereClause.pin_to_sidebar = true;
} else if (pin_to_sidebar === 'false') {
whereClause.pin_to_sidebar = false;
}
// Filter by area
if (area_id && area_id !== '') {
whereClause.area_id = area_id;
}
const projects = await Project.findAll({
where: whereClause,
include: [
{
model: Task,
required: false,
attributes: ['id', 'status']
},
{
model: Area,
required: false,
attributes: ['name']
},
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] }
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
],
order: [['name', 'ASC']]
});
const { grouped } = req.query;
// Calculate task status counts for each project
const taskStatusCounts = {};
const enhancedProjects = projects.map(project => {
const tasks = project.Tasks || [];
const taskStatus = {
total: tasks.length,
done: tasks.filter(t => t.status === 2).length,
in_progress: tasks.filter(t => t.status === 1).length,
not_started: tasks.filter(t => t.status === 0).length
};
taskStatusCounts[project.id] = taskStatus;
const projectJson = project.toJSON();
return {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at),
task_status: taskStatus,
completion_percentage: taskStatus.total > 0 ? Math.round((taskStatus.done / taskStatus.total) * 100) : 0
};
});
const { active, pin_to_sidebar, area_id } = req.query;
// If grouped=true, return grouped format
if (grouped === 'true') {
const groupedProjects = {};
enhancedProjects.forEach(project => {
const areaName = project.Area ? project.Area.name : 'No Area';
if (!groupedProjects[areaName]) {
groupedProjects[areaName] = [];
let whereClause = { user_id: req.session.userId };
// Filter by active status
if (active === 'true') {
whereClause.active = true;
} else if (active === 'false') {
whereClause.active = false;
}
groupedProjects[areaName].push(project);
});
res.json(groupedProjects);
} else {
res.json({
projects: enhancedProjects
});
// Filter by pinned status
if (pin_to_sidebar === 'true') {
whereClause.pin_to_sidebar = true;
} else if (pin_to_sidebar === 'false') {
whereClause.pin_to_sidebar = false;
}
// Filter by area
if (area_id && area_id !== '') {
whereClause.area_id = area_id;
}
const projects = await Project.findAll({
where: whereClause,
include: [
{
model: Task,
required: false,
attributes: ['id', 'status'],
},
{
model: Area,
required: false,
attributes: ['name'],
},
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
order: [['name', 'ASC']],
});
const { grouped } = req.query;
// Calculate task status counts for each project
const taskStatusCounts = {};
const enhancedProjects = projects.map((project) => {
const tasks = project.Tasks || [];
const taskStatus = {
total: tasks.length,
done: tasks.filter((t) => t.status === 2).length,
in_progress: tasks.filter((t) => t.status === 1).length,
not_started: tasks.filter((t) => t.status === 0).length,
};
taskStatusCounts[project.id] = taskStatus;
const projectJson = project.toJSON();
return {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at),
task_status: taskStatus,
completion_percentage:
taskStatus.total > 0
? Math.round((taskStatus.done / taskStatus.total) * 100)
: 0,
};
});
// If grouped=true, return grouped format
if (grouped === 'true') {
const groupedProjects = {};
enhancedProjects.forEach((project) => {
const areaName = project.Area ? project.Area.name : 'No Area';
if (!groupedProjects[areaName]) {
groupedProjects[areaName] = [];
}
groupedProjects[areaName].push(project);
});
res.json(groupedProjects);
} else {
res.json({
projects: enhancedProjects,
});
}
} catch (error) {
console.error('Error fetching projects:', error);
res.status(500).json({ error: 'Internal server error' });
}
} catch (error) {
console.error('Error fetching projects:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/project/:id
router.get('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{
model: Task,
required: false,
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
required: false,
},
],
},
{ model: Area, required: false, attributes: ['id', 'name'] },
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
});
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const projectJson = project.toJSON();
const result = {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at),
};
res.json(result);
} catch (error) {
console.error('Error fetching project:', error);
res.status(500).json({ error: 'Internal server error' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
include: [
{
model: Task,
required: false,
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
required: false
}
]
},
{ model: Area, required: false, attributes: ['id', 'name'] },
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const projectJson = project.toJSON();
const result = {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at)
};
res.json(result);
} catch (error) {
console.error('Error fetching project:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/project
router.post('/project', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const {
name,
description,
area_id,
priority,
due_date_at,
image_url,
tags,
Tags,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Project name is required' });
}
const projectData = {
name: name.trim(),
description: description || '',
area_id: area_id || null,
active: true,
pin_to_sidebar: false,
priority: priority || null,
due_date_at: due_date_at || null,
image_url: image_url || null,
user_id: req.session.userId,
};
const project = await Project.create(projectData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
});
const projectJson = projectWithAssociations.toJSON();
res.status(201).json({
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at),
});
} catch (error) {
console.error('Error creating project:', error);
res.status(400).json({
error: 'There was a problem creating the project.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const { name, description, area_id, priority, due_date_at, image_url, tags, Tags } = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Project name is required' });
}
const projectData = {
name: name.trim(),
description: description || '',
area_id: area_id || null,
active: true,
pin_to_sidebar: false,
priority: priority || null,
due_date_at: due_date_at || null,
image_url: image_url || null,
user_id: req.session.userId
};
const project = await Project.create(projectData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
const projectJson = projectWithAssociations.toJSON();
res.status(201).json({
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at)
});
} catch (error) {
console.error('Error creating project:', error);
res.status(400).json({
error: 'There was a problem creating the project.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// PATCH /api/project/:id
router.patch('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
const {
name,
description,
area_id,
active,
pin_to_sidebar,
priority,
due_date_at,
image_url,
tags,
Tags,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (area_id !== undefined) updateData.area_id = area_id;
if (active !== undefined) updateData.active = active;
if (pin_to_sidebar !== undefined)
updateData.pin_to_sidebar = pin_to_sidebar;
if (priority !== undefined) updateData.priority = priority;
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
if (image_url !== undefined) updateData.image_url = image_url;
await project.update(updateData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
],
});
const projectJson = projectWithAssociations.toJSON();
res.json({
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at),
});
} catch (error) {
console.error('Error updating project:', error);
res.status(400).json({
error: 'There was a problem updating the project.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
const { name, description, area_id, active, pin_to_sidebar, priority, due_date_at, image_url, tags, Tags } = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (area_id !== undefined) updateData.area_id = area_id;
if (active !== undefined) updateData.active = active;
if (pin_to_sidebar !== undefined) updateData.pin_to_sidebar = pin_to_sidebar;
if (priority !== undefined) updateData.priority = priority;
if (due_date_at !== undefined) updateData.due_date_at = due_date_at;
if (image_url !== undefined) updateData.image_url = image_url;
await project.update(updateData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
]
});
const projectJson = projectWithAssociations.toJSON();
res.json({
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at)
});
} catch (error) {
console.error('Error updating project:', error);
res.status(400).json({
error: 'There was a problem updating the project.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// DELETE /api/project/:id
router.delete('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
await project.destroy();
res.json({ message: 'Project successfully deleted' });
} catch (error) {
console.error('Error deleting project:', error);
res.status(400).json({
error: 'There was a problem deleting the project.',
});
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId }
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
await project.destroy();
res.json({ message: 'Project successfully deleted' });
} catch (error) {
console.error('Error deleting project:', error);
res.status(400).json({ error: 'There was a problem deleting the project.' });
}
});
module.exports = router;
module.exports = router;

View file

@ -4,27 +4,27 @@ const quotesService = require('../services/quotesService');
// GET /api/quotes/random - Get a random quote
router.get('/quotes/random', (req, res) => {
try {
const quote = quotesService.getRandomQuote();
res.json({ quote });
} catch (error) {
console.error('Error getting random quote:', error);
res.status(500).json({ error: 'Internal server error' });
}
try {
const quote = quotesService.getRandomQuote();
res.json({ quote });
} catch (error) {
console.error('Error getting random quote:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/quotes - Get all quotes
router.get('/quotes', (req, res) => {
try {
const quotes = quotesService.getAllQuotes();
res.json({
quotes,
count: quotesService.getQuotesCount()
});
} catch (error) {
console.error('Error getting quotes:', error);
res.status(500).json({ error: 'Internal server error' });
}
try {
const quotes = quotesService.getAllQuotes();
res.json({
quotes,
count: quotesService.getQuotesCount(),
});
} catch (error) {
console.error('Error getting quotes:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
module.exports = router;

View file

@ -4,152 +4,161 @@ const router = express.Router();
// GET /api/tags
router.get('/tags', async (req, res) => {
try {
const tags = await Tag.findAll({
where: { user_id: req.currentUser.id },
attributes: ['id', 'name'],
order: [['name', 'ASC']]
});
try {
const tags = await Tag.findAll({
where: { user_id: req.currentUser.id },
attributes: ['id', 'name'],
order: [['name', 'ASC']],
});
res.json(tags);
} catch (error) {
console.error('Error fetching tags:', error);
res.status(500).json({ error: 'Internal server error' });
}
res.json(tags);
} catch (error) {
console.error('Error fetching tags:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/tag/:id
router.get('/tag/:id', async (req, res) => {
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
attributes: ['id', 'name']
});
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
attributes: ['id', 'name'],
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
res.json(tag);
} catch (error) {
console.error('Error fetching tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
res.json(tag);
} catch (error) {
console.error('Error fetching tag:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/tag
router.post('/tag', async (req, res) => {
try {
const { name } = req.body;
try {
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
}
const tag = await Tag.create({
name: name.trim(),
user_id: req.currentUser.id,
});
res.status(201).json({
id: tag.id,
name: tag.name,
});
} catch (error) {
console.error('Error creating tag:', error);
res.status(400).json({
error: 'There was a problem creating the tag.',
});
}
const tag = await Tag.create({
name: name.trim(),
user_id: req.currentUser.id
});
res.status(201).json({
id: tag.id,
name: tag.name
});
} catch (error) {
console.error('Error creating tag:', error);
res.status(400).json({ error: 'There was a problem creating the tag.' });
}
});
// PATCH /api/tag/:id
router.patch('/tag/:id', async (req, res) => {
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
});
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
if (!tag) {
return res.status(404).json({ error: 'Tag not found' });
}
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
}
await tag.update({ name: name.trim() });
res.json({
id: tag.id,
name: tag.name,
});
} catch (error) {
console.error('Error updating tag:', error);
res.status(400).json({
error: 'There was a problem updating the tag.',
});
}
const { name } = req.body;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tag name is required' });
}
await tag.update({ name: name.trim() });
res.json({
id: tag.id,
name: tag.name
});
} catch (error) {
console.error('Error updating tag:', error);
res.status(400).json({ error: 'There was a problem updating the tag.' });
}
});
// DELETE /api/tag/:id
router.delete('/tag/:id', async (req, res) => {
const transaction = await sequelize.transaction();
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
const transaction = await sequelize.transaction();
if (!tag) {
await transaction.rollback();
return res.status(404).json({ error: 'Tag not found' });
}
try {
const tag = await Tag.findOne({
where: { id: req.params.id, user_id: req.currentUser.id },
});
// Use transaction to ensure all deletions happen atomically
// Remove all associations before deleting the tag by manually deleting from junction tables
// Only delete from tables that exist
try {
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction
});
} catch (error) {
// Ignore if table doesn't exist
console.log('tasks_tags table not found, skipping');
}
try {
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction
});
} catch (error) {
// Ignore if table doesn't exist
console.log('notes_tags table not found, skipping');
}
try {
await sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', {
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction
});
} catch (error) {
// Ignore if table doesn't exist
console.log('projects_tags table not found, skipping');
}
if (!tag) {
await transaction.rollback();
return res.status(404).json({ error: 'Tag not found' });
}
// Now safely delete the tag
await tag.destroy({ transaction });
await transaction.commit();
res.json({ message: 'Tag successfully deleted' });
} catch (error) {
await transaction.rollback();
console.error('Error deleting tag:', error);
res.status(400).json({ error: 'There was a problem deleting the tag.' });
}
// Use transaction to ensure all deletions happen atomically
// Remove all associations before deleting the tag by manually deleting from junction tables
// Only delete from tables that exist
try {
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction,
});
} catch (error) {
// Ignore if table doesn't exist
console.log('tasks_tags table not found, skipping');
}
try {
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction,
});
} catch (error) {
// Ignore if table doesn't exist
console.log('notes_tags table not found, skipping');
}
try {
await sequelize.query(
'DELETE FROM projects_tags WHERE tag_id = ?',
{
replacements: [tag.id],
type: sequelize.QueryTypes.DELETE,
transaction,
}
);
} catch (error) {
// Ignore if table doesn't exist
console.log('projects_tags table not found, skipping');
}
// Now safely delete the tag
await tag.destroy({ transaction });
await transaction.commit();
res.json({ message: 'Tag successfully deleted' });
} catch (error) {
await transaction.rollback();
console.error('Error deleting tag:', error);
res.status(400).json({
error: 'There was a problem deleting the tag.',
});
}
});
module.exports = router;
module.exports = router;

View file

@ -5,149 +5,166 @@ const router = express.Router();
// GET /api/task/:id/timeline - Get task event timeline
router.get('/task/:id/timeline', async (req, res) => {
try {
const timeline = await TaskEventService.getTaskTimeline(req.params.id);
// Filter to only show events for tasks owned by the current user
const userTimeline = timeline.filter(event => event.user_id === req.currentUser.id);
res.json(userTimeline);
} catch (error) {
console.error('Error fetching task timeline:', error);
res.status(500).json({ error: 'Failed to fetch task timeline' });
}
try {
const timeline = await TaskEventService.getTaskTimeline(req.params.id);
// Filter to only show events for tasks owned by the current user
const userTimeline = timeline.filter(
(event) => event.user_id === req.currentUser.id
);
res.json(userTimeline);
} catch (error) {
console.error('Error fetching task timeline:', error);
res.status(500).json({ error: 'Failed to fetch task timeline' });
}
});
// GET /api/task/:id/completion-time - Get task completion analytics
router.get('/task/:id/completion-time', async (req, res) => {
try {
const completionTime = await TaskEventService.getTaskCompletionTime(req.params.id);
if (!completionTime) {
return res.status(404).json({ error: 'Task completion data not found' });
}
try {
const completionTime = await TaskEventService.getTaskCompletionTime(
req.params.id
);
res.json(completionTime);
} catch (error) {
console.error('Error fetching task completion time:', error);
res.status(500).json({ error: 'Failed to fetch task completion time' });
}
if (!completionTime) {
return res
.status(404)
.json({ error: 'Task completion data not found' });
}
res.json(completionTime);
} catch (error) {
console.error('Error fetching task completion time:', error);
res.status(500).json({ error: 'Failed to fetch task completion time' });
}
});
// GET /api/user/productivity-metrics - Get user productivity metrics
router.get('/user/productivity-metrics', async (req, res) => {
try {
const { startDate, endDate } = req.query;
const metrics = await TaskEventService.getUserProductivityMetrics(
req.currentUser.id,
startDate ? new Date(startDate) : null,
endDate ? new Date(endDate) : null
);
try {
const { startDate, endDate } = req.query;
res.json(metrics);
} catch (error) {
console.error('Error fetching productivity metrics:', error);
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
}
const metrics = await TaskEventService.getUserProductivityMetrics(
req.currentUser.id,
startDate ? new Date(startDate) : null,
endDate ? new Date(endDate) : null
);
res.json(metrics);
} catch (error) {
console.error('Error fetching productivity metrics:', error);
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
}
});
// GET /api/user/activity-summary - Get task activity summary
router.get('/user/activity-summary', async (req, res) => {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ error: 'startDate and endDate are required' });
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res
.status(400)
.json({ error: 'startDate and endDate are required' });
}
const activitySummary = await TaskEventService.getTaskActivitySummary(
req.currentUser.id,
new Date(startDate),
new Date(endDate)
);
res.json(activitySummary);
} catch (error) {
console.error('Error fetching activity summary:', error);
res.status(500).json({ error: 'Failed to fetch activity summary' });
}
const activitySummary = await TaskEventService.getTaskActivitySummary(
req.currentUser.id,
new Date(startDate),
new Date(endDate)
);
res.json(activitySummary);
} catch (error) {
console.error('Error fetching activity summary:', error);
res.status(500).json({ error: 'Failed to fetch activity summary' });
}
});
// GET /api/tasks/completion-analytics - Get completion time analytics for multiple tasks
router.get('/tasks/completion-analytics', async (req, res) => {
try {
const { limit = 50, offset = 0, projectId } = req.query;
// Get completed tasks for the user
const { Task, Project } = require('../models');
const { Op } = require('sequelize');
const whereClause = {
user_id: req.currentUser.id,
status: 2 // completed
};
if (projectId) {
whereClause.project_id = projectId;
}
const completedTasks = await Task.findAll({
where: whereClause,
include: [
{ model: Project, attributes: ['name'], required: false }
],
order: [['completed_at', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset)
});
try {
const { limit = 50, offset = 0, projectId } = req.query;
// Get completion time analytics for each task
const analytics = [];
for (const task of completedTasks) {
const completionTime = await TaskEventService.getTaskCompletionTime(task.id);
if (completionTime) {
analytics.push({
task_id: task.id,
task_name: task.name,
project_name: task.Project?.name || null,
...completionTime
// Get completed tasks for the user
const { Task, Project } = require('../models');
const { Op } = require('sequelize');
const whereClause = {
user_id: req.currentUser.id,
status: 2, // completed
};
if (projectId) {
whereClause.project_id = projectId;
}
const completedTasks = await Task.findAll({
where: whereClause,
include: [
{ model: Project, attributes: ['name'], required: false },
],
order: [['completed_at', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset),
});
}
// Get completion time analytics for each task
const analytics = [];
for (const task of completedTasks) {
const completionTime = await TaskEventService.getTaskCompletionTime(
task.id
);
if (completionTime) {
analytics.push({
task_id: task.id,
task_name: task.name,
project_name: task.Project?.name || null,
...completionTime,
});
}
}
// Calculate summary statistics
const summary = {
total_tasks: analytics.length,
average_completion_hours:
analytics.length > 0
? analytics.reduce((sum, a) => sum + a.duration_hours, 0) /
analytics.length
: 0,
median_completion_hours: 0,
fastest_completion:
analytics.length > 0
? Math.min(...analytics.map((a) => a.duration_hours))
: 0,
slowest_completion:
analytics.length > 0
? Math.max(...analytics.map((a) => a.duration_hours))
: 0,
};
// Calculate median
if (analytics.length > 0) {
const sorted = analytics
.map((a) => a.duration_hours)
.sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
summary.median_completion_hours =
sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
}
res.json({
tasks: analytics,
summary,
});
} catch (error) {
console.error('Error fetching completion analytics:', error);
res.status(500).json({ error: 'Failed to fetch completion analytics' });
}
// Calculate summary statistics
const summary = {
total_tasks: analytics.length,
average_completion_hours: analytics.length > 0
? analytics.reduce((sum, a) => sum + a.duration_hours, 0) / analytics.length
: 0,
median_completion_hours: 0,
fastest_completion: analytics.length > 0
? Math.min(...analytics.map(a => a.duration_hours))
: 0,
slowest_completion: analytics.length > 0
? Math.max(...analytics.map(a => a.duration_hours))
: 0
};
// Calculate median
if (analytics.length > 0) {
const sorted = analytics.map(a => a.duration_hours).sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
summary.median_completion_hours = sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
}
res.json({
tasks: analytics,
summary
});
} catch (error) {
console.error('Error fetching completion analytics:', error);
res.status(500).json({ error: 'Failed to fetch completion analytics' });
}
});
module.exports = router;
module.exports = router;

File diff suppressed because it is too large Load diff

View file

@ -5,105 +5,113 @@ const router = express.Router();
// POST /api/telegram/start-polling
router.post('/telegram/start-polling', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user || !user.telegram_bot_token) {
return res.status(400).json({ error: 'Telegram bot token not set.' });
}
const user = await User.findByPk(req.session.userId);
if (!user || !user.telegram_bot_token) {
return res
.status(400)
.json({ error: 'Telegram bot token not set.' });
}
const success = await telegramPoller.addUser(user);
const success = await telegramPoller.addUser(user);
if (success) {
res.json({
success: true,
message: 'Telegram polling started',
status: telegramPoller.getStatus()
});
} else {
res.status(500).json({ error: 'Failed to start Telegram polling.' });
if (success) {
res.json({
success: true,
message: 'Telegram polling started',
status: telegramPoller.getStatus(),
});
} else {
res.status(500).json({
error: 'Failed to start Telegram polling.',
});
}
} catch (error) {
console.error('Error starting Telegram polling:', error);
res.status(500).json({ error: 'Failed to start Telegram polling.' });
}
} catch (error) {
console.error('Error starting Telegram polling:', error);
res.status(500).json({ error: 'Failed to start Telegram polling.' });
}
});
// POST /api/telegram/stop-polling
router.post('/telegram/stop-polling', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const success = telegramPoller.removeUser(req.session.userId);
res.json({
success: true,
message: 'Telegram polling stopped',
status: telegramPoller.getStatus(),
});
} catch (error) {
console.error('Error stopping Telegram polling:', error);
res.status(500).json({ error: 'Failed to stop Telegram polling.' });
}
const success = telegramPoller.removeUser(req.session.userId);
res.json({
success: true,
message: 'Telegram polling stopped',
status: telegramPoller.getStatus()
});
} catch (error) {
console.error('Error stopping Telegram polling:', error);
res.status(500).json({ error: 'Failed to stop Telegram polling.' });
}
});
// GET /api/telegram/polling-status
router.get('/telegram/polling-status', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
res.json({
success: true,
status: telegramPoller.getStatus()
});
} catch (error) {
console.error('Error getting Telegram polling status:', error);
res.status(500).json({ error: 'Internal server error' });
}
res.json({
success: true,
status: telegramPoller.getStatus(),
});
} catch (error) {
console.error('Error getting Telegram polling status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/telegram/setup
router.post('/telegram/setup', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { token } = req.body;
if (!token) {
return res
.status(400)
.json({ error: 'Telegram bot token is required.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
// Basic token validation - check if it looks like a Telegram bot token
if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) {
return res
.status(400)
.json({ error: 'Invalid Telegram bot token format.' });
}
// Update user's telegram bot token
await user.update({ telegram_bot_token: token });
res.json({
success: true,
message: 'Telegram bot token updated successfully',
token: token,
});
} catch (error) {
console.error('Error setting up Telegram:', error);
res.status(500).json({ error: 'Internal server error' });
}
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Telegram bot token is required.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
// Basic token validation - check if it looks like a Telegram bot token
if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) {
return res.status(400).json({ error: 'Invalid Telegram bot token format.' });
}
// Update user's telegram bot token
await user.update({ telegram_bot_token: token });
res.json({
success: true,
message: 'Telegram bot token updated successfully',
token: token
});
} catch (error) {
console.error('Error setting up Telegram:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
module.exports = router;

View file

@ -6,335 +6,378 @@ const router = express.Router();
// Fast regex-based metadata extraction (much faster than cheerio for head content)
function extractMetadataFromHtml(html) {
try {
// Extract title with priority: og:title > twitter:title > title tag
let title = null;
// Try og:title first
const ogTitleMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
if (ogTitleMatch) {
title = ogTitleMatch[1];
} else {
// Try twitter:title
const twitterTitleMatch = html.match(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i);
if (twitterTitleMatch) {
title = twitterTitleMatch[1];
} else {
// Fallback to title tag
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) {
title = titleMatch[1].trim();
try {
// Extract title with priority: og:title > twitter:title > title tag
let title = null;
// Try og:title first
const ogTitleMatch = html.match(
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i
);
if (ogTitleMatch) {
title = ogTitleMatch[1];
} else {
// Try twitter:title
const twitterTitleMatch = html.match(
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i
);
if (twitterTitleMatch) {
title = twitterTitleMatch[1];
} else {
// Fallback to title tag
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch) {
title = titleMatch[1].trim();
}
}
}
}
}
// Clean up title
if (title) {
title = title.trim();
// Decode common HTML entities
title = title
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
if (title.length > 100) {
title = title.substring(0, 100) + '...';
}
}
// Extract image with priority: og:image > twitter:image
let image = null;
const ogImageMatch = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
if (ogImageMatch) {
image = ogImageMatch[1];
} else {
const twitterImageMatch = html.match(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
if (twitterImageMatch) {
image = twitterImageMatch[1];
}
}
// Extract description
let description = null;
const ogDescMatch = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
if (ogDescMatch) {
description = ogDescMatch[1];
} else {
const twitterDescMatch = html.match(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i);
if (twitterDescMatch) {
description = twitterDescMatch[1];
} else {
const metaDescMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
if (metaDescMatch) {
description = metaDescMatch[1];
// Clean up title
if (title) {
title = title.trim();
// Decode common HTML entities
title = title
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
if (title.length > 100) {
title = title.substring(0, 100) + '...';
}
}
}
// Extract image with priority: og:image > twitter:image
let image = null;
const ogImageMatch = html.match(
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i
);
if (ogImageMatch) {
image = ogImageMatch[1];
} else {
const twitterImageMatch = html.match(
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i
);
if (twitterImageMatch) {
image = twitterImageMatch[1];
}
}
// Extract description
let description = null;
const ogDescMatch = html.match(
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i
);
if (ogDescMatch) {
description = ogDescMatch[1];
} else {
const twitterDescMatch = html.match(
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i
);
if (twitterDescMatch) {
description = twitterDescMatch[1];
} else {
const metaDescMatch = html.match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i
);
if (metaDescMatch) {
description = metaDescMatch[1];
}
}
}
if (description && description.length > 150) {
description = description.substring(0, 150) + '...';
}
return {
title,
image,
description,
};
} catch (error) {
console.error('Error parsing HTML:', error);
return { title: null, image: null, description: null };
}
if (description && description.length > 150) {
description = description.substring(0, 150) + '...';
}
return {
title,
image,
description
};
} catch (error) {
console.error('Error parsing HTML:', error);
return { title: null, image: null, description: null };
}
}
// Helper function to check if text is a URL
function isUrl(text) {
const urlRegex = /^(https?:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
return urlRegex.test(text.trim());
const urlRegex =
/^(https?:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
return urlRegex.test(text.trim());
}
// Helper function to resolve relative URLs to absolute URLs
function resolveUrl(baseUrl, relativeUrl) {
try {
return new URL(relativeUrl, baseUrl).href;
} catch {
return relativeUrl;
}
try {
return new URL(relativeUrl, baseUrl).href;
} catch {
return relativeUrl;
}
}
// Helper function to handle YouTube URLs specially
function handleYouTubeUrl(url) {
const youtubeRegex = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const match = url.match(youtubeRegex);
if (match) {
const videoId = match[1];
// For now, return basic YouTube info - this is fast and reliable
return {
title: 'YouTube Video',
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
description: 'YouTube video'
};
}
return null;
const youtubeRegex =
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
const match = url.match(youtubeRegex);
if (match) {
const videoId = match[1];
// For now, return basic YouTube info - this is fast and reliable
return {
title: 'YouTube Video',
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
description: 'YouTube video',
};
}
return null;
}
// Helper function to fetch URL metadata with redirect handling
async function fetchUrlMetadata(url, maxRedirects = 5) {
return new Promise((resolve) => {
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url;
}
return new Promise((resolve) => {
// Add protocol if missing
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'http://' + url;
}
// Handle YouTube URLs specially to avoid anti-bot issues
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const youtubeMetadata = handleYouTubeUrl(url);
if (youtubeMetadata) {
resolve(youtubeMetadata);
} else {
resolve(null);
}
return;
}
// Global timeout for the entire operation
const globalTimeout = setTimeout(() => {
resolve(null);
}, 3000); // 3 second max for entire operation
function makeRequest(currentUrl, redirectCount = 0) {
if (redirectCount > maxRedirects) {
clearTimeout(globalTimeout);
resolve(null);
return;
}
try {
const urlObj = new URL(currentUrl);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: 2000, // Reduced from 5000ms to 2000ms
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
};
const req = client.request(options, (res) => {
// Handle redirects (301, 302, 303, 307, 308)
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
const redirectUrl = new URL(res.headers.location, currentUrl).href;
makeRequest(redirectUrl, redirectCount + 1);
// Handle YouTube URLs specially to avoid anti-bot issues
if (url.includes('youtube.com') || url.includes('youtu.be')) {
const youtubeMetadata = handleYouTubeUrl(url);
if (youtubeMetadata) {
resolve(youtubeMetadata);
} else {
resolve(null);
}
return;
}
}
// If not a successful response, resolve with null
if (res.statusCode < 200 || res.statusCode >= 400) {
clearTimeout(globalTimeout);
// Global timeout for the entire operation
const globalTimeout = setTimeout(() => {
resolve(null);
return;
}
}, 3000); // 3 second max for entire operation
let data = '';
let totalBytes = 0;
const maxBytes = 20000; // Reduced from 100KB to 20KB - most meta tags are in head
let foundMeta = false;
res.on('data', (chunk) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
clearTimeout(globalTimeout);
req.destroy();
return;
function makeRequest(currentUrl, redirectCount = 0) {
if (redirectCount > maxRedirects) {
clearTimeout(globalTimeout);
resolve(null);
return;
}
data += chunk;
// Early termination if we've found essential meta tags and closed head
if (!foundMeta && (data.includes('og:title') || data.includes('twitter:title') || data.includes('</title>'))) {
foundMeta = true;
try {
const urlObj = new URL(currentUrl);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'GET',
timeout: 2000, // Reduced from 5000ms to 2000ms
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
};
const req = client.request(options, (res) => {
// Handle redirects (301, 302, 303, 307, 308)
if (
[301, 302, 303, 307, 308].includes(res.statusCode) &&
res.headers.location
) {
const redirectUrl = new URL(
res.headers.location,
currentUrl
).href;
makeRequest(redirectUrl, redirectCount + 1);
return;
}
// If not a successful response, resolve with null
if (res.statusCode < 200 || res.statusCode >= 400) {
clearTimeout(globalTimeout);
resolve(null);
return;
}
let data = '';
let totalBytes = 0;
const maxBytes = 20000; // Reduced from 100KB to 20KB - most meta tags are in head
let foundMeta = false;
res.on('data', (chunk) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
clearTimeout(globalTimeout);
req.destroy();
return;
}
data += chunk;
// Early termination if we've found essential meta tags and closed head
if (
!foundMeta &&
(data.includes('og:title') ||
data.includes('twitter:title') ||
data.includes('</title>'))
) {
foundMeta = true;
}
// Stop early if we have meta tags and hit end of head
if (foundMeta && data.includes('</head>')) {
clearTimeout(globalTimeout);
req.destroy();
return;
}
});
res.on('end', () => {
clearTimeout(globalTimeout);
const metadata = extractMetadataFromHtml(data);
// Resolve relative image URLs to absolute
if (
metadata.image &&
!metadata.image.startsWith('http')
) {
metadata.image = resolveUrl(
currentUrl,
metadata.image
);
}
resolve(metadata);
});
});
req.on('error', (err) => {
clearTimeout(globalTimeout);
resolve(null);
});
req.on('timeout', () => {
clearTimeout(globalTimeout);
req.destroy();
resolve(null);
});
req.end();
} catch (error) {
clearTimeout(globalTimeout);
resolve(null);
}
// Stop early if we have meta tags and hit end of head
if (foundMeta && data.includes('</head>')) {
clearTimeout(globalTimeout);
req.destroy();
return;
}
});
}
res.on('end', () => {
clearTimeout(globalTimeout);
const metadata = extractMetadataFromHtml(data);
// Resolve relative image URLs to absolute
if (metadata.image && !metadata.image.startsWith('http')) {
metadata.image = resolveUrl(currentUrl, metadata.image);
}
resolve(metadata);
});
});
req.on('error', (err) => {
clearTimeout(globalTimeout);
resolve(null);
});
req.on('timeout', () => {
clearTimeout(globalTimeout);
req.destroy();
resolve(null);
});
req.end();
} catch (error) {
clearTimeout(globalTimeout);
resolve(null);
}
}
makeRequest(url);
});
makeRequest(url);
});
}
// GET /api/url/title
router.get('/url/title', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const { url } = req.query;
const metadata = await fetchUrlMetadata(url);
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
if (metadata && metadata.title) {
res.json({
url,
title: metadata.title,
image: metadata.image,
description: metadata.description
});
} else {
res.json({ url, title: null, image: null, description: null, error: 'Could not extract metadata' });
const metadata = await fetchUrlMetadata(url);
if (metadata && metadata.title) {
res.json({
url,
title: metadata.title,
image: metadata.image,
description: metadata.description,
});
} else {
res.json({
url,
title: null,
image: null,
description: null,
error: 'Could not extract metadata',
});
}
} catch (error) {
console.error('Error extracting URL title:', error);
res.status(500).json({ error: 'Internal server error' });
}
} catch (error) {
console.error('Error extracting URL title:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/url/extract-from-text
router.post('/url/extract-from-text', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { text } = req.body;
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: 'Text parameter is required' });
}
if (!text) {
return res
.status(400)
.json({ error: 'Text parameter is required' });
}
// Enhanced URL extraction - look for URLs with or without protocol
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/gi;
const urlWithoutProtocolRegex = /(?:^|\s)((?:www\.)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\s]*)?)/gi;
let urls = text.match(urlWithProtocolRegex);
// If no URLs with protocol found, look for URLs without protocol
if (!urls) {
const matches = text.match(urlWithoutProtocolRegex);
if (matches) {
// Clean up the matches (remove leading whitespace)
urls = matches.map(match => match.trim());
}
}
// Enhanced URL extraction - look for URLs with or without protocol
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/gi;
const urlWithoutProtocolRegex =
/(?:^|\s)((?:www\.)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\s]*)?)/gi;
if (urls && urls.length > 0) {
const firstUrl = urls[0];
const metadata = await fetchUrlMetadata(firstUrl);
if (metadata && metadata.title) {
res.json({
found: true,
url: firstUrl,
title: metadata.title,
image: metadata.image,
description: metadata.description,
originalText: text
});
} else {
res.json({
found: true,
url: firstUrl,
title: null,
image: null,
description: null,
originalText: text
});
}
} else {
res.json({ found: false });
let urls = text.match(urlWithProtocolRegex);
// If no URLs with protocol found, look for URLs without protocol
if (!urls) {
const matches = text.match(urlWithoutProtocolRegex);
if (matches) {
// Clean up the matches (remove leading whitespace)
urls = matches.map((match) => match.trim());
}
}
if (urls && urls.length > 0) {
const firstUrl = urls[0];
const metadata = await fetchUrlMetadata(firstUrl);
if (metadata && metadata.title) {
res.json({
found: true,
url: firstUrl,
title: metadata.title,
image: metadata.image,
description: metadata.description,
originalText: text,
});
} else {
res.json({
found: true,
url: firstUrl,
title: null,
image: null,
description: null,
originalText: text,
});
}
} else {
res.json({ found: false });
}
} catch (error) {
console.error('Error extracting URL from text:', error);
res.status(500).json({ error: 'Internal server error' });
}
} catch (error) {
console.error('Error extracting URL from text:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
module.exports = router;

View file

@ -3,332 +3,423 @@ const { User } = require('../models');
const taskSummaryService = require('../services/taskSummaryService');
const router = express.Router();
const VALID_FREQUENCIES = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
const VALID_FREQUENCIES = [
'daily',
'weekdays',
'weekly',
'1h',
'2h',
'4h',
'8h',
'12h',
];
// GET /api/profile
router.get('/profile', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId, {
attributes: [
'id',
'email',
'appearance',
'language',
'timezone',
'avatar_image',
'telegram_bot_token',
'telegram_chat_id',
'task_summary_enabled',
'task_summary_frequency',
'task_intelligence_enabled',
'auto_suggest_next_actions_enabled',
'pomodoro_enabled',
'today_settings',
],
});
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
// Parse today_settings if it's a string
if (user.today_settings && typeof user.today_settings === 'string') {
try {
user.today_settings = JSON.parse(user.today_settings);
} catch (error) {
console.error('Error parsing today_settings:', error);
user.today_settings = null;
}
}
res.json(user);
} catch (error) {
console.error('Error fetching profile:', error);
res.status(500).json({ error: 'Internal server error' });
}
const user = await User.findByPk(req.session.userId, {
attributes: [
'id', 'email', 'appearance', 'language', 'timezone',
'avatar_image', 'telegram_bot_token', 'telegram_chat_id',
'task_summary_enabled', 'task_summary_frequency', 'task_intelligence_enabled',
'auto_suggest_next_actions_enabled', 'pomodoro_enabled', 'today_settings'
]
});
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
// Parse today_settings if it's a string
if (user.today_settings && typeof user.today_settings === 'string') {
try {
user.today_settings = JSON.parse(user.today_settings);
} catch (error) {
console.error('Error parsing today_settings:', error);
user.today_settings = null;
}
}
res.json(user);
} catch (error) {
console.error('Error fetching profile:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// PATCH /api/profile
router.patch('/profile', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'Profile not found.' });
}
const { appearance, language, timezone, avatar_image, telegram_bot_token, task_intelligence_enabled, task_summary_enabled, task_summary_frequency, auto_suggest_next_actions_enabled, pomodoro_enabled, currentPassword, newPassword } = req.body;
const allowedUpdates = {};
if (appearance !== undefined) allowedUpdates.appearance = appearance;
if (language !== undefined) allowedUpdates.language = language;
if (timezone !== undefined) allowedUpdates.timezone = timezone;
if (avatar_image !== undefined) allowedUpdates.avatar_image = avatar_image;
if (telegram_bot_token !== undefined) allowedUpdates.telegram_bot_token = telegram_bot_token;
if (task_intelligence_enabled !== undefined) allowedUpdates.task_intelligence_enabled = task_intelligence_enabled;
if (task_summary_enabled !== undefined) allowedUpdates.task_summary_enabled = task_summary_enabled;
if (task_summary_frequency !== undefined) allowedUpdates.task_summary_frequency = task_summary_frequency;
if (auto_suggest_next_actions_enabled !== undefined) allowedUpdates.auto_suggest_next_actions_enabled = auto_suggest_next_actions_enabled;
if (pomodoro_enabled !== undefined) allowedUpdates.pomodoro_enabled = pomodoro_enabled;
const {
appearance,
language,
timezone,
avatar_image,
telegram_bot_token,
task_intelligence_enabled,
task_summary_enabled,
task_summary_frequency,
auto_suggest_next_actions_enabled,
pomodoro_enabled,
currentPassword,
newPassword,
} = req.body;
// Handle password change if provided
if (currentPassword && newPassword) {
if (newPassword.length < 6) {
return res.status(400).json({
field: 'newPassword',
error: 'Password must be at least 6 characters'
const allowedUpdates = {};
if (appearance !== undefined) allowedUpdates.appearance = appearance;
if (language !== undefined) allowedUpdates.language = language;
if (timezone !== undefined) allowedUpdates.timezone = timezone;
if (avatar_image !== undefined)
allowedUpdates.avatar_image = avatar_image;
if (telegram_bot_token !== undefined)
allowedUpdates.telegram_bot_token = telegram_bot_token;
if (task_intelligence_enabled !== undefined)
allowedUpdates.task_intelligence_enabled =
task_intelligence_enabled;
if (task_summary_enabled !== undefined)
allowedUpdates.task_summary_enabled = task_summary_enabled;
if (task_summary_frequency !== undefined)
allowedUpdates.task_summary_frequency = task_summary_frequency;
if (auto_suggest_next_actions_enabled !== undefined)
allowedUpdates.auto_suggest_next_actions_enabled =
auto_suggest_next_actions_enabled;
if (pomodoro_enabled !== undefined)
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
// Handle password change if provided
if (currentPassword && newPassword) {
if (newPassword.length < 6) {
return res.status(400).json({
field: 'newPassword',
error: 'Password must be at least 6 characters',
});
}
// Verify current password
const isValidPassword = await User.checkPassword(
currentPassword,
user.password_digest
);
if (!isValidPassword) {
return res.status(400).json({
field: 'currentPassword',
error: 'Current password is incorrect',
});
}
// Hash and include new password in updates
const hashedNewPassword = await User.hashPassword(newPassword);
allowedUpdates.password_digest = hashedNewPassword;
}
await user.update(allowedUpdates);
// Return updated user with limited fields
const updatedUser = await User.findByPk(user.id, {
attributes: [
'id',
'email',
'appearance',
'language',
'timezone',
'avatar_image',
'telegram_bot_token',
'telegram_chat_id',
'task_intelligence_enabled',
'task_summary_enabled',
'task_summary_frequency',
'auto_suggest_next_actions_enabled',
'pomodoro_enabled',
],
});
}
// Verify current password
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
if (!isValidPassword) {
return res.status(400).json({
field: 'currentPassword',
error: 'Current password is incorrect'
res.json(updatedUser);
} catch (error) {
console.error('Error updating profile:', error);
res.status(400).json({
error: 'Failed to update profile.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
// Hash and include new password in updates
const hashedNewPassword = await User.hashPassword(newPassword);
allowedUpdates.password_digest = hashedNewPassword;
}
await user.update(allowedUpdates);
// Return updated user with limited fields
const updatedUser = await User.findByPk(user.id, {
attributes: ['id', 'email', 'appearance', 'language', 'timezone', 'avatar_image', 'telegram_bot_token', 'telegram_chat_id', 'task_intelligence_enabled', 'task_summary_enabled', 'task_summary_frequency', 'auto_suggest_next_actions_enabled', 'pomodoro_enabled']
});
res.json(updatedUser);
} catch (error) {
console.error('Error updating profile:', error);
res.status(400).json({
error: 'Failed to update profile.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/change-password
router.post('/profile/change-password', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
error: 'Current password and new password are required',
});
}
if (newPassword.length < 6) {
return res.status(400).json({
field: 'newPassword',
error: 'Password must be at least 6 characters',
});
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const isValidPassword = await User.checkPassword(
currentPassword,
user.password_digest
);
if (!isValidPassword) {
return res.status(400).json({
field: 'currentPassword',
error: 'Current password is incorrect',
});
}
// Hash and update new password
const hashedNewPassword = await User.hashPassword(newPassword);
await user.update({ password_digest: hashedNewPassword });
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Error changing password:', error);
res.status(500).json({ error: 'Internal server error' });
}
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current password and new password are required' });
}
if (newPassword.length < 6) {
return res.status(400).json({
field: 'newPassword',
error: 'Password must be at least 6 characters'
});
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
if (!isValidPassword) {
return res.status(400).json({
field: 'currentPassword',
error: 'Current password is incorrect'
});
}
// Hash and update new password
const hashedNewPassword = await User.hashPassword(newPassword);
await user.update({ password_digest: hashedNewPassword });
res.json({ message: 'Password changed successfully' });
} catch (error) {
console.error('Error changing password:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/profile/task-summary/toggle
router.post('/profile/task-summary/toggle', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const enabled = !user.task_summary_enabled;
await user.update({ task_summary_enabled: enabled });
// Note: Telegram integration would need to be implemented separately
const message = enabled
? 'Task summary notifications have been enabled.'
: 'Task summary notifications have been disabled.';
res.json({
success: true,
enabled: enabled,
message: message,
});
} catch (error) {
console.error('Error toggling task summary:', error);
res.status(400).json({
error: 'Failed to update task summary settings.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const enabled = !user.task_summary_enabled;
await user.update({ task_summary_enabled: enabled });
// Note: Telegram integration would need to be implemented separately
const message = enabled
? 'Task summary notifications have been enabled.'
: 'Task summary notifications have been disabled.';
res.json({
success: true,
enabled: enabled,
message: message
});
} catch (error) {
console.error('Error toggling task summary:', error);
res.status(400).json({
error: 'Failed to update task summary settings.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/task-summary/frequency
router.post('/profile/task-summary/frequency', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { frequency } = req.body;
if (!frequency) {
return res.status(400).json({ error: 'Frequency is required.' });
}
if (!VALID_FREQUENCIES.includes(frequency)) {
return res.status(400).json({ error: 'Invalid frequency value.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
await user.update({ task_summary_frequency: frequency });
res.json({
success: true,
frequency: frequency,
message: `Task summary frequency has been set to ${frequency}.`,
});
} catch (error) {
console.error('Error updating task summary frequency:', error);
res.status(400).json({
error: 'Failed to update task summary frequency.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
const { frequency } = req.body;
if (!frequency) {
return res.status(400).json({ error: 'Frequency is required.' });
}
if (!VALID_FREQUENCIES.includes(frequency)) {
return res.status(400).json({ error: 'Invalid frequency value.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
await user.update({ task_summary_frequency: frequency });
res.json({
success: true,
frequency: frequency,
message: `Task summary frequency has been set to ${frequency}.`
});
} catch (error) {
console.error('Error updating task summary frequency:', error);
res.status(400).json({
error: 'Failed to update task summary frequency.',
details: error.errors ? error.errors.map(e => e.message) : [error.message]
});
}
});
// POST /api/profile/task-summary/send-now
router.post('/profile/task-summary/send-now', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
if (!user.telegram_bot_token || !user.telegram_chat_id) {
return res.status(400).json({ error: 'Telegram bot is not properly configured.' });
}
if (!user.telegram_bot_token || !user.telegram_chat_id) {
return res
.status(400)
.json({ error: 'Telegram bot is not properly configured.' });
}
// Send the task summary
const success = await taskSummaryService.sendSummaryToUser(user.id);
if (success) {
res.json({
success: true,
message: 'Task summary was sent to your Telegram.'
});
} else {
res.status(400).json({ error: 'Failed to send message to Telegram.' });
// Send the task summary
const success = await taskSummaryService.sendSummaryToUser(user.id);
if (success) {
res.json({
success: true,
message: 'Task summary was sent to your Telegram.',
});
} else {
res.status(400).json({
error: 'Failed to send message to Telegram.',
});
}
} catch (error) {
console.error('Error sending task summary:', error);
res.status(400).json({
error: 'Error sending message to Telegram.',
details: error.message,
});
}
} catch (error) {
console.error('Error sending task summary:', error);
res.status(400).json({
error: 'Error sending message to Telegram.',
details: error.message
});
}
});
// GET /api/profile/task-summary/status
router.get('/profile/task-summary/status', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
res.json({
success: true,
enabled: user.task_summary_enabled,
frequency: user.task_summary_frequency,
last_run: user.task_summary_last_run,
next_run: user.task_summary_next_run
});
} catch (error) {
console.error('Error fetching task summary status:', error);
res.status(500).json({ error: 'Internal server error' });
}
res.json({
success: true,
enabled: user.task_summary_enabled,
frequency: user.task_summary_frequency,
last_run: user.task_summary_last_run,
next_run: user.task_summary_next_run,
});
} catch (error) {
console.error('Error fetching task summary status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// PUT /api/profile/today-settings
router.put('/profile/today-settings', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const {
showMetrics,
showProductivity,
showIntelligence,
showDueToday,
showCompleted,
showProgressBar,
showDailyQuote,
} = req.body;
const todaySettings = {
showMetrics:
showMetrics !== undefined
? showMetrics
: user.today_settings?.showMetrics || false,
showProductivity:
showProductivity !== undefined
? showProductivity
: user.today_settings?.showProductivity || false,
showIntelligence:
showIntelligence !== undefined
? showIntelligence
: user.today_settings?.showIntelligence || false,
showDueToday:
showDueToday !== undefined
? showDueToday
: user.today_settings?.showDueToday || true,
showCompleted:
showCompleted !== undefined
? showCompleted
: user.today_settings?.showCompleted || true,
showProgressBar: true, // Always enabled - ignore any attempts to disable it
showDailyQuote:
showDailyQuote !== undefined
? showDailyQuote
: user.today_settings?.showDailyQuote || true,
};
await user.update({ today_settings: todaySettings });
res.json({
success: true,
today_settings: todaySettings,
});
} catch (error) {
console.error('Error updating today settings:', error);
res.status(500).json({ error: 'Internal server error' });
}
const user = await User.findByPk(req.session.userId);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const {
showMetrics,
showProductivity,
showIntelligence,
showDueToday,
showCompleted,
showProgressBar,
showDailyQuote
} = req.body;
const todaySettings = {
showMetrics: showMetrics !== undefined ? showMetrics : user.today_settings?.showMetrics || false,
showProductivity: showProductivity !== undefined ? showProductivity : user.today_settings?.showProductivity || false,
showIntelligence: showIntelligence !== undefined ? showIntelligence : user.today_settings?.showIntelligence || false,
showDueToday: showDueToday !== undefined ? showDueToday : user.today_settings?.showDueToday || true,
showCompleted: showCompleted !== undefined ? showCompleted : user.today_settings?.showCompleted || true,
showProgressBar: true, // Always enabled - ignore any attempts to disable it
showDailyQuote: showDailyQuote !== undefined ? showDailyQuote : user.today_settings?.showDailyQuote || true
};
await user.update({ today_settings: todaySettings });
res.json({
success: true,
today_settings: todaySettings
});
} catch (error) {
console.error('Error updating today settings:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
module.exports = router;

View file

@ -9,19 +9,21 @@ require('dotenv').config();
const { sequelize } = require('../models');
async function initDatabase() {
try {
console.log('Initializing database...');
console.log('WARNING: This will drop all existing data!');
await sequelize.sync({ force: true });
console.log('✅ Database initialized successfully');
console.log('All tables have been created and existing data has been cleared');
process.exit(0);
} catch (error) {
console.error('❌ Error initializing database:', error.message);
process.exit(1);
}
try {
console.log('Initializing database...');
console.log('WARNING: This will drop all existing data!');
await sequelize.sync({ force: true });
console.log('✅ Database initialized successfully');
console.log(
'All tables have been created and existing data has been cleared'
);
process.exit(0);
} catch (error) {
console.error('❌ Error initializing database:', error.message);
process.exit(1);
}
}
initDatabase();
initDatabase();

View file

@ -9,19 +9,19 @@ require('dotenv').config();
const { sequelize } = require('../models');
async function migrateDatabase() {
try {
console.log('Migrating database...');
console.log('This will alter existing tables to match current models');
await sequelize.sync({ alter: true });
console.log('✅ Database migrated successfully');
console.log('All tables have been updated to match current models');
process.exit(0);
} catch (error) {
console.error('❌ Error migrating database:', error.message);
process.exit(1);
}
try {
console.log('Migrating database...');
console.log('This will alter existing tables to match current models');
await sequelize.sync({ alter: true });
console.log('✅ Database migrated successfully');
console.log('All tables have been updated to match current models');
process.exit(0);
} catch (error) {
console.error('❌ Error migrating database:', error.message);
process.exit(1);
}
}
migrateDatabase();
migrateDatabase();

View file

@ -9,19 +9,19 @@ require('dotenv').config();
const { sequelize } = require('../models');
async function resetDatabase() {
try {
console.log('Resetting database...');
console.log('WARNING: This will permanently delete all data!');
await sequelize.sync({ force: true });
console.log('✅ Database reset successfully');
console.log('All tables have been dropped and recreated');
process.exit(0);
} catch (error) {
console.error('❌ Error resetting database:', error.message);
process.exit(1);
}
try {
console.log('Resetting database...');
console.log('WARNING: This will permanently delete all data!');
await sequelize.sync({ force: true });
console.log('✅ Database reset successfully');
console.log('All tables have been dropped and recreated');
process.exit(0);
} catch (error) {
console.error('❌ Error resetting database:', error.message);
process.exit(1);
}
}
resetDatabase();
resetDatabase();

View file

@ -6,64 +6,75 @@
*/
require('dotenv').config();
const { sequelize, User, Task, Project, Area, Note, Tag, InboxItem } = require('../models');
const {
sequelize,
User,
Task,
Project,
Area,
Note,
Tag,
InboxItem,
} = require('../models');
const fs = require('fs');
const path = require('path');
async function checkDatabaseStatus() {
try {
console.log('🔍 Checking database status...\n');
// Check database file
const dbConfig = sequelize.config || sequelize.options;
const dbPath = dbConfig.storage || sequelize.options.storage;
console.log('📂 Database Configuration:');
console.log(` Storage: ${dbPath}`);
console.log(` Dialect: ${dbConfig.dialect || sequelize.options.dialect || 'sqlite'}`);
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`);
// Check if database file exists
if (fs.existsSync(dbPath)) {
const stats = fs.statSync(dbPath);
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
console.log(` Last modified: ${stats.mtime.toISOString()}`);
} else {
console.log(' ⚠️ Database file does not exist');
try {
console.log('🔍 Checking database status...\n');
// Check database file
const dbConfig = sequelize.config || sequelize.options;
const dbPath = dbConfig.storage || sequelize.options.storage;
console.log('📂 Database Configuration:');
console.log(` Storage: ${dbPath}`);
console.log(
` Dialect: ${dbConfig.dialect || sequelize.options.dialect || 'sqlite'}`
);
console.log(` Environment: ${process.env.NODE_ENV || 'development'}`);
// Check if database file exists
if (fs.existsSync(dbPath)) {
const stats = fs.statSync(dbPath);
console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
console.log(` Last modified: ${stats.mtime.toISOString()}`);
} else {
console.log(' ⚠️ Database file does not exist');
}
console.log('\n🔌 Testing database connection...');
await sequelize.authenticate();
console.log('✅ Database connection successful\n');
// Get table information
console.log('📊 Table Statistics:');
const models = [
{ name: 'Users', model: User },
{ name: 'Areas', model: Area },
{ name: 'Projects', model: Project },
{ name: 'Tasks', model: Task },
{ name: 'Notes', model: Note },
{ name: 'Tags', model: Tag },
{ name: 'Inbox Items', model: InboxItem },
];
for (const { name, model } of models) {
try {
const count = await model.count();
console.log(` ${name}: ${count} records`);
} catch (error) {
console.log(` ${name}: ❌ Error (${error.message})`);
}
}
console.log('\n✅ Database status check completed');
process.exit(0);
} catch (error) {
console.error('\n❌ Database connection failed:', error.message);
console.error('\n💡 Try running: npm run db:init');
process.exit(1);
}
console.log('\n🔌 Testing database connection...');
await sequelize.authenticate();
console.log('✅ Database connection successful\n');
// Get table information
console.log('📊 Table Statistics:');
const models = [
{ name: 'Users', model: User },
{ name: 'Areas', model: Area },
{ name: 'Projects', model: Project },
{ name: 'Tasks', model: Task },
{ name: 'Notes', model: Note },
{ name: 'Tags', model: Tag },
{ name: 'Inbox Items', model: InboxItem }
];
for (const { name, model } of models) {
try {
const count = await model.count();
console.log(` ${name}: ${count} records`);
} catch (error) {
console.log(` ${name}: ❌ Error (${error.message})`);
}
}
console.log('\n✅ Database status check completed');
process.exit(0);
} catch (error) {
console.error('\n❌ Database connection failed:', error.message);
console.error('\n💡 Try running: npm run db:init');
process.exit(1);
}
}
checkDatabaseStatus();
checkDatabaseStatus();

View file

@ -9,18 +9,18 @@ require('dotenv').config();
const { sequelize } = require('../models');
async function syncDatabase() {
try {
console.log('Syncing database...');
await sequelize.sync();
console.log('✅ Database synchronized successfully');
console.log('All tables have been created (existing data preserved)');
process.exit(0);
} catch (error) {
console.error('❌ Error syncing database:', error.message);
process.exit(1);
}
try {
console.log('Syncing database...');
await sequelize.sync();
console.log('✅ Database synchronized successfully');
console.log('All tables have been created (existing data preserved)');
process.exit(0);
} catch (error) {
console.error('❌ Error syncing database:', error.message);
process.exit(1);
}
}
syncDatabase();
syncDatabase();

View file

@ -10,28 +10,31 @@ const fs = require('fs');
const path = require('path');
function createMigration() {
const migrationName = process.argv[2];
if (!migrationName) {
console.error('❌ Usage: npm run migration:create <migration-name>');
console.error('Example: npm run migration:create add-description-to-tasks');
process.exit(1);
}
const migrationName = process.argv[2];
// Generate timestamp (YYYYMMDDHHMMSS format)
const now = new Date();
const timestamp = now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0');
if (!migrationName) {
console.error('❌ Usage: npm run migration:create <migration-name>');
console.error(
'Example: npm run migration:create add-description-to-tasks'
);
process.exit(1);
}
const fileName = `${timestamp}-${migrationName}.js`;
const filePath = path.join(__dirname, '..', 'migrations', fileName);
// Generate timestamp (YYYYMMDDHHMMSS format)
const now = new Date();
const timestamp =
now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0') +
now.getSeconds().toString().padStart(2, '0');
// Migration template
const template = `'use strict';
const fileName = `${timestamp}-${migrationName}.js`;
const filePath = path.join(__dirname, '..', 'migrations', fileName);
// Migration template
const template = `'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
@ -87,22 +90,22 @@ module.exports = {
}
};`;
try {
fs.writeFileSync(filePath, template);
console.log('✅ Migration created successfully');
console.log(`📁 File: ${fileName}`);
console.log(`📂 Path: ${filePath}`);
console.log('');
console.log('📝 Next steps:');
console.log('1. Edit the migration file to add your schema changes');
console.log('2. Run: npm run migration:run');
console.log('3. To rollback: npm run migration:undo');
process.exit(0);
} catch (error) {
console.error('❌ Error creating migration:', error.message);
process.exit(1);
}
try {
fs.writeFileSync(filePath, template);
console.log('✅ Migration created successfully');
console.log(`📁 File: ${fileName}`);
console.log(`📂 Path: ${filePath}`);
console.log('');
console.log('📝 Next steps:');
console.log('1. Edit the migration file to add your schema changes');
console.log('2. Run: npm run migration:run');
console.log('3. To rollback: npm run migration:undo');
process.exit(0);
} catch (error) {
console.error('❌ Error creating migration:', error.message);
process.exit(1);
}
}
createMigration();
createMigration();

View file

@ -8,11 +8,11 @@ process.env.NODE_ENV = process.env.NODE_ENV || 'development';
// Ensure we're using the correct database path
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = `sqlite:///${path.join(__dirname, '../db/development.sqlite3')}`;
process.env.DATABASE_URL = `sqlite:///${path.join(__dirname, '../db/development.sqlite3')}`;
}
console.log('🌱 Starting development data seeding...');
console.log(`📁 Database: ${process.env.DATABASE_URL}`);
console.log(`🌍 Environment: ${process.env.NODE_ENV}`);
seedDatabase();
seedDatabase();

View file

@ -11,67 +11,71 @@ const { User } = require('../models');
const bcrypt = require('bcrypt');
async function createUser() {
const [email, password] = process.argv.slice(2);
if (!email || password === undefined) {
console.error('❌ Usage: npm run user:create <email> <password>');
console.error('Example: npm run user:create admin@example.com mypassword123');
process.exit(1);
}
const [email, password] = process.argv.slice(2);
// Basic password validation (check for empty or short passwords)
if (!password || password.length < 6) {
console.error('❌ Password must be at least 6 characters long');
process.exit(1);
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Check for common invalid patterns
if (!email.includes('@') ||
!email.includes('.') ||
email.includes('@@') ||
email.includes(' ') ||
email.startsWith('@') ||
email.endsWith('@') ||
email.endsWith('.') ||
email.includes('@.') ||
email.includes('.@') ||
!emailRegex.test(email)) {
console.error('❌ Invalid email format');
process.exit(1);
}
try {
console.log(`Creating user with email: ${email}`);
// Check if user already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
console.error(`❌ User with email ${email} already exists`);
process.exit(1);
if (!email || password === undefined) {
console.error('❌ Usage: npm run user:create <email> <password>');
console.error(
'Example: npm run user:create admin@example.com mypassword123'
);
process.exit(1);
}
// Basic password validation (check for empty or short passwords)
if (!password || password.length < 6) {
console.error('❌ Password must be at least 6 characters long');
process.exit(1);
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Check for common invalid patterns
if (
!email.includes('@') ||
!email.includes('.') ||
email.includes('@@') ||
email.includes(' ') ||
email.startsWith('@') ||
email.endsWith('@') ||
email.endsWith('.') ||
email.includes('@.') ||
email.includes('.@') ||
!emailRegex.test(email)
) {
console.error('❌ Invalid email format');
process.exit(1);
}
try {
console.log(`Creating user with email: ${email}`);
// Check if user already exists
const existingUser = await User.findOne({ where: { email } });
if (existingUser) {
console.error(`❌ User with email ${email} already exists`);
process.exit(1);
}
// Hash the password manually since the hook might not be working in this context
const hashedPassword = await bcrypt.hash(password, 10);
// Create the user
const user = await User.create({
email,
password_digest: hashedPassword,
});
console.log('✅ User created successfully');
console.log(`📧 Email: ${user.email}`);
console.log(`🆔 User ID: ${user.id}`);
console.log(`📅 Created: ${user.created_at}`);
process.exit(0);
} catch (error) {
console.error('❌ Error creating user:', error.message);
process.exit(1);
}
// Hash the password manually since the hook might not be working in this context
const hashedPassword = await bcrypt.hash(password, 10);
// Create the user
const user = await User.create({
email,
password_digest: hashedPassword
});
console.log('✅ User created successfully');
console.log(`📧 Email: ${user.email}`);
console.log(`🆔 User ID: ${user.id}`);
console.log(`📅 Created: ${user.created_at}`);
process.exit(0);
} catch (error) {
console.error('❌ Error creating user:', error.message);
process.exit(1);
}
}
createUser();
createUser();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,128 +4,119 @@ const yaml = require('js-yaml');
// 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."
"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.',
];
// get quotes file path
const getQuotesFilePath = () =>
path.join(__dirname, '../config/quotes.yml');
const getQuotesFilePath = () => path.join(__dirname, '../config/quotes.yml');
// Side effect function to check if file exists
const fileExists = (filePath) =>
fs.existsSync(filePath);
const fileExists = (filePath) => fs.existsSync(filePath);
// Side effect function to read file contents
const readFileContents = (filePath) =>
fs.readFileSync(filePath, 'utf8');
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}`);
}
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));
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;
if (validateQuotesData(data)) {
return data.quotes;
}
return null;
};
// Side effect function to load quotes from file
const loadQuotesFromFile = () => {
try {
const quotesPath = getQuotesFilePath();
if (!fileExists(quotesPath)) {
console.warn('Quotes configuration file not found, using defaults');
return createDefaultQuotes();
}
try {
const quotesPath = getQuotesFilePath();
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();
if (!fileExists(quotesPath)) {
console.warn('Quotes configuration file not found, using defaults');
return createDefaultQuotes();
}
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();
}
} catch (error) {
console.error('Error loading quotes:', error.message);
return createDefaultQuotes();
}
};
// get random index
const getRandomIndex = (arrayLength) =>
Math.floor(Math.random() * arrayLength);
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];
if (quotes.length === 0) {
return 'Stay focused and keep going!';
}
const randomIndex = getRandomIndex(quotes.length);
return quotes[randomIndex];
};
// get all quotes
const getAllQuotesFromArray = (quotes) =>
[...quotes]; // Return copy to maintain immutability
const getAllQuotesFromArray = (quotes) => [...quotes]; // Return copy to maintain immutability
// get quotes count
const getQuotesCount = (quotes) =>
quotes.length;
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;
quotes = loadQuotesFromFile();
return quotes;
};
// get random quote
const getRandomQuote = () =>
getRandomQuoteFromArray(quotes);
const getRandomQuote = () => getRandomQuoteFromArray(quotes);
// get all quotes
const getAllQuotes = () =>
getAllQuotesFromArray(quotes);
const getAllQuotes = () => getAllQuotesFromArray(quotes);
// get count
const getCount = () =>
getQuotesCount(quotes);
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
};
getRandomQuote,
getAllQuotes,
getQuotesCount: getCount,
reloadQuotes,
// For testing
_createDefaultQuotes: createDefaultQuotes,
_getQuotesFilePath: getQuotesFilePath,
_parseYamlContent: parseYamlContent,
_validateQuotesData: validateQuotesData,
_extractQuotes: extractQuotes,
_getRandomIndex: getRandomIndex,
_getRandomQuoteFromArray: getRandomQuoteFromArray,
};

View file

@ -5,411 +5,463 @@ const { Op } = require('sequelize');
* Service for managing recurring tasks
*/
class RecurringTaskService {
/**
* Generate new tasks from recurring task templates
* @param {number} userId - Optional user ID to limit processing
* @returns {Promise<Array>} Array of newly created tasks
*/
static async generateRecurringTasks(userId = null) {
try {
const whereClause = {
recurrence_type: { [Op.ne]: 'none' },
status: { [Op.ne]: Task.STATUS.ARCHIVED }
};
/**
* Generate new tasks from recurring task templates
* @param {number} userId - Optional user ID to limit processing
* @returns {Promise<Array>} Array of newly created tasks
*/
static async generateRecurringTasks(userId = null) {
try {
const whereClause = {
recurrence_type: { [Op.ne]: 'none' },
status: { [Op.ne]: Task.STATUS.ARCHIVED },
};
if (userId) {
whereClause.user_id = userId;
}
if (userId) {
whereClause.user_id = userId;
}
// Find all recurring tasks that need processing
const recurringTasks = await Task.findAll({
where: whereClause,
order: [['last_generated_date', 'ASC']]
});
// Find all recurring tasks that need processing
const recurringTasks = await Task.findAll({
where: whereClause,
order: [['last_generated_date', 'ASC']],
});
const newTasks = [];
const now = new Date();
const newTasks = [];
const now = new Date();
for (const task of recurringTasks) {
const generatedTasks = await this.processRecurringTask(task, now);
newTasks.push(...generatedTasks);
}
for (const task of recurringTasks) {
const generatedTasks = await this.processRecurringTask(
task,
now
);
newTasks.push(...generatedTasks);
}
return newTasks;
} catch (error) {
console.error('Error generating recurring tasks:', error);
throw error;
}
}
/**
* Process a single recurring task and generate new instances if needed
* @param {Object} task - The recurring task template
* @param {Date} now - Current timestamp
* @returns {Promise<Array>} Array of newly created task instances
*/
static async processRecurringTask(task, now) {
const newTasks = [];
// Skip if recurrence has ended
if (task.recurrence_end_date && now > task.recurrence_end_date) {
return newTasks;
}
let nextDueDate = this.calculateNextDueDate(task, now);
// Generate tasks up to current date
while (nextDueDate && nextDueDate <= now) {
// Check if this due date already has a task instance
const existingTask = await Task.findOne({
where: {
user_id: task.user_id,
name: task.name,
due_date: nextDueDate,
project_id: task.project_id
return newTasks;
} catch (error) {
console.error('Error generating recurring tasks:', error);
throw error;
}
});
if (!existingTask) {
const newTask = await this.createTaskInstance(task, nextDueDate);
newTasks.push(newTask);
}
// Update last generated date
task.last_generated_date = nextDueDate;
await task.save();
// Calculate next due date
nextDueDate = this.calculateNextDueDate(task, nextDueDate);
// Safety check to prevent infinite loops
if (newTasks.length > 100) {
console.warn(`Generated 100+ tasks for recurring task ${task.id}, stopping to prevent overflow`);
break;
}
}
return newTasks;
}
/**
* Process a single recurring task and generate new instances if needed
* @param {Object} task - The recurring task template
* @param {Date} now - Current timestamp
* @returns {Promise<Array>} Array of newly created task instances
*/
static async processRecurringTask(task, now) {
const newTasks = [];
/**
* Create a new task instance from a recurring task template
* @param {Object} template - The recurring task template
* @param {Date} dueDate - Due date for the new task instance
* @returns {Promise<Object>} The newly created task
*/
static async createTaskInstance(template, dueDate) {
const taskData = {
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: Task.STATUS.NOT_STARTED,
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none', // Instances are not recurring themselves
recurring_parent_id: template.id // Link to the original recurring task
};
// Skip if recurrence has ended
if (task.recurrence_end_date && now > task.recurrence_end_date) {
return newTasks;
}
return await Task.create(taskData);
}
let nextDueDate = this.calculateNextDueDate(task, now);
/**
* Calculate the next due date for a recurring task
* @param {Object} task - The recurring task
* @param {Date} fromDate - Date to calculate from
* @returns {Date|null} Next due date or null if no more recurrences
*/
static calculateNextDueDate(task, fromDate) {
// Handle invalid inputs
if (!task || !task.recurrence_type || !fromDate || isNaN(fromDate.getTime())) {
return null;
// Generate tasks up to current date
while (nextDueDate && nextDueDate <= now) {
// Check if this due date already has a task instance
const existingTask = await Task.findOne({
where: {
user_id: task.user_id,
name: task.name,
due_date: nextDueDate,
project_id: task.project_id,
},
});
if (!existingTask) {
const newTask = await this.createTaskInstance(
task,
nextDueDate
);
newTasks.push(newTask);
}
// Update last generated date
task.last_generated_date = nextDueDate;
await task.save();
// Calculate next due date
nextDueDate = this.calculateNextDueDate(task, nextDueDate);
// Safety check to prevent infinite loops
if (newTasks.length > 100) {
console.warn(
`Generated 100+ tasks for recurring task ${task.id}, stopping to prevent overflow`
);
break;
}
}
return newTasks;
}
const baseDate = task.completion_based ?
(task.last_generated_date || task.created_at) :
(task.due_date || task.created_at);
// If no base date is available, use fromDate
const startDate = baseDate ?
new Date(Math.max(fromDate.getTime(), baseDate.getTime())) :
new Date(fromDate.getTime());
switch (task.recurrence_type) {
case 'daily':
return this.calculateDailyRecurrence(startDate, task.recurrence_interval || 1);
case 'weekly':
return this.calculateWeeklyRecurrence(startDate, task.recurrence_interval || 1, task.recurrence_weekday);
case 'monthly':
return this.calculateMonthlyRecurrence(startDate, task.recurrence_interval || 1, task.recurrence_month_day);
case 'monthly_weekday':
return this.calculateMonthlyWeekdayRecurrence(
startDate,
task.recurrence_interval || 1,
task.recurrence_weekday,
task.recurrence_week_of_month
/**
* Create a new task instance from a recurring task template
* @param {Object} template - The recurring task template
* @param {Date} dueDate - Due date for the new task instance
* @returns {Promise<Object>} The newly created task
*/
static async createTaskInstance(template, dueDate) {
const taskData = {
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: Task.STATUS.NOT_STARTED,
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none', // Instances are not recurring themselves
recurring_parent_id: template.id, // Link to the original recurring task
};
return await Task.create(taskData);
}
/**
* Calculate the next due date for a recurring task
* @param {Object} task - The recurring task
* @param {Date} fromDate - Date to calculate from
* @returns {Date|null} Next due date or null if no more recurrences
*/
static calculateNextDueDate(task, fromDate) {
// Handle invalid inputs
if (
!task ||
!task.recurrence_type ||
!fromDate ||
isNaN(fromDate.getTime())
) {
return null;
}
const baseDate = task.completion_based
? task.last_generated_date || task.created_at
: task.due_date || task.created_at;
// If no base date is available, use fromDate
const startDate = baseDate
? new Date(Math.max(fromDate.getTime(), baseDate.getTime()))
: new Date(fromDate.getTime());
switch (task.recurrence_type) {
case 'daily':
return this.calculateDailyRecurrence(
startDate,
task.recurrence_interval || 1
);
case 'weekly':
return this.calculateWeeklyRecurrence(
startDate,
task.recurrence_interval || 1,
task.recurrence_weekday
);
case 'monthly':
return this.calculateMonthlyRecurrence(
startDate,
task.recurrence_interval || 1,
task.recurrence_month_day
);
case 'monthly_weekday':
return this.calculateMonthlyWeekdayRecurrence(
startDate,
task.recurrence_interval || 1,
task.recurrence_weekday,
task.recurrence_week_of_month
);
case 'monthly_last_day':
return this.calculateMonthlyLastDayRecurrence(
startDate,
task.recurrence_interval || 1
);
default:
return null;
}
}
/**
* Calculate next daily recurrence
* @param {Date} fromDate - Starting date
* @param {number} interval - Days between recurrences
* @returns {Date} Next due date
*/
static calculateDailyRecurrence(fromDate, interval) {
const nextDate = new Date(fromDate);
nextDate.setDate(nextDate.getDate() + interval);
return nextDate;
}
/**
* Calculate next weekly recurrence
* @param {Date} fromDate - Starting date
* @param {number} interval - Weeks between recurrences
* @param {number} weekday - Target day of week (0=Sunday, 6=Saturday)
* @returns {Date} Next due date
*/
static calculateWeeklyRecurrence(fromDate, interval, weekday) {
const nextDate = new Date(fromDate);
if (weekday !== null && weekday !== undefined) {
// Find next occurrence of the specified weekday
const currentWeekday = nextDate.getDay();
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
if (
daysUntilTarget === 0 &&
nextDate.getTime() === fromDate.getTime()
) {
// If today is the target weekday and we're calculating from today, add interval weeks
nextDate.setDate(nextDate.getDate() + interval * 7);
} else {
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
if (nextDate <= fromDate) {
nextDate.setDate(nextDate.getDate() + interval * 7);
}
}
} else {
// No specific weekday, just add interval weeks
nextDate.setDate(nextDate.getDate() + interval * 7);
}
return nextDate;
}
/**
* Calculate next monthly recurrence on specific day
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @param {number} dayOfMonth - Target day of month (1-31)
* @returns {Date} Next due date
*/
static calculateMonthlyRecurrence(fromDate, interval, dayOfMonth) {
const nextDate = new Date(fromDate);
const targetDay = dayOfMonth || fromDate.getUTCDate();
// Move to target month
const targetMonth = nextDate.getUTCMonth() + interval;
const targetYear =
nextDate.getUTCFullYear() + Math.floor(targetMonth / 12);
const finalMonth = targetMonth % 12;
// Get the max day for the target month
const maxDay = new Date(
Date.UTC(targetYear, finalMonth + 1, 0)
).getUTCDate();
const finalDay = Math.min(targetDay, maxDay);
// Create the new date
const result = new Date(
Date.UTC(
targetYear,
finalMonth,
finalDay,
fromDate.getUTCHours(),
fromDate.getUTCMinutes(),
fromDate.getUTCSeconds(),
fromDate.getUTCMilliseconds()
)
);
case 'monthly_last_day':
return this.calculateMonthlyLastDayRecurrence(startDate, task.recurrence_interval || 1);
default:
return null;
return result;
}
}
/**
* Calculate next daily recurrence
* @param {Date} fromDate - Starting date
* @param {number} interval - Days between recurrences
* @returns {Date} Next due date
*/
static calculateDailyRecurrence(fromDate, interval) {
const nextDate = new Date(fromDate);
nextDate.setDate(nextDate.getDate() + interval);
return nextDate;
}
/**
* Calculate next monthly recurrence on specific weekday of month
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @param {number} weekday - Target weekday (0=Sunday, 6=Saturday)
* @param {number} weekOfMonth - Which occurrence in month (1-5)
* @returns {Date} Next due date
*/
static calculateMonthlyWeekdayRecurrence(
fromDate,
interval,
weekday,
weekOfMonth
) {
const nextDate = new Date(fromDate);
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
/**
* Calculate next weekly recurrence
* @param {Date} fromDate - Starting date
* @param {number} interval - Weeks between recurrences
* @param {number} weekday - Target day of week (0=Sunday, 6=Saturday)
* @returns {Date} Next due date
*/
static calculateWeeklyRecurrence(fromDate, interval, weekday) {
const nextDate = new Date(fromDate);
if (weekday !== null && weekday !== undefined) {
// Find next occurrence of the specified weekday
const currentWeekday = nextDate.getDay();
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
if (daysUntilTarget === 0 && nextDate.getTime() === fromDate.getTime()) {
// If today is the target weekday and we're calculating from today, add interval weeks
nextDate.setDate(nextDate.getDate() + (interval * 7));
} else {
nextDate.setDate(nextDate.getDate() + daysUntilTarget);
if (nextDate <= fromDate) {
nextDate.setDate(nextDate.getDate() + (interval * 7));
// Find the first day of the month
const firstOfMonth = new Date(
Date.UTC(nextDate.getUTCFullYear(), nextDate.getUTCMonth(), 1)
);
const firstWeekday = firstOfMonth.getUTCDay();
// Calculate the first occurrence of the target weekday
const daysToAdd = (weekday - firstWeekday + 7) % 7;
const firstOccurrence = new Date(firstOfMonth);
firstOccurrence.setUTCDate(1 + daysToAdd);
// Add weeks to get to the target week of month
const targetDate = new Date(firstOccurrence);
targetDate.setUTCDate(
firstOccurrence.getUTCDate() + (weekOfMonth - 1) * 7
);
// Make sure we're still in the same month
if (targetDate.getUTCMonth() !== nextDate.getUTCMonth()) {
// Week doesn't exist in this month, use last occurrence
targetDate.setUTCDate(targetDate.getUTCDate() - 7);
}
}
} else {
// No specific weekday, just add interval weeks
nextDate.setDate(nextDate.getDate() + (interval * 7));
}
return nextDate;
}
/**
* Calculate next monthly recurrence on specific day
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @param {number} dayOfMonth - Target day of month (1-31)
* @returns {Date} Next due date
*/
static calculateMonthlyRecurrence(fromDate, interval, dayOfMonth) {
const nextDate = new Date(fromDate);
const targetDay = dayOfMonth || fromDate.getUTCDate();
// Move to target month
const targetMonth = nextDate.getUTCMonth() + interval;
const targetYear = nextDate.getUTCFullYear() + Math.floor(targetMonth / 12);
const finalMonth = targetMonth % 12;
// Get the max day for the target month
const maxDay = new Date(Date.UTC(targetYear, finalMonth + 1, 0)).getUTCDate();
const finalDay = Math.min(targetDay, maxDay);
// Create the new date
const result = new Date(Date.UTC(
targetYear,
finalMonth,
finalDay,
fromDate.getUTCHours(),
fromDate.getUTCMinutes(),
fromDate.getUTCSeconds(),
fromDate.getUTCMilliseconds()
));
return result;
}
// Preserve the original time
targetDate.setUTCHours(
fromDate.getUTCHours(),
fromDate.getUTCMinutes(),
fromDate.getUTCSeconds(),
fromDate.getUTCMilliseconds()
);
/**
* Calculate next monthly recurrence on specific weekday of month
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @param {number} weekday - Target weekday (0=Sunday, 6=Saturday)
* @param {number} weekOfMonth - Which occurrence in month (1-5)
* @returns {Date} Next due date
*/
static calculateMonthlyWeekdayRecurrence(fromDate, interval, weekday, weekOfMonth) {
const nextDate = new Date(fromDate);
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
// Find the first day of the month
const firstOfMonth = new Date(Date.UTC(nextDate.getUTCFullYear(), nextDate.getUTCMonth(), 1));
const firstWeekday = firstOfMonth.getUTCDay();
// Calculate the first occurrence of the target weekday
const daysToAdd = (weekday - firstWeekday + 7) % 7;
const firstOccurrence = new Date(firstOfMonth);
firstOccurrence.setUTCDate(1 + daysToAdd);
// Add weeks to get to the target week of month
const targetDate = new Date(firstOccurrence);
targetDate.setUTCDate(firstOccurrence.getUTCDate() + ((weekOfMonth - 1) * 7));
// Make sure we're still in the same month
if (targetDate.getUTCMonth() !== nextDate.getUTCMonth()) {
// Week doesn't exist in this month, use last occurrence
targetDate.setUTCDate(targetDate.getUTCDate() - 7);
}
// Preserve the original time
targetDate.setUTCHours(fromDate.getUTCHours(), fromDate.getUTCMinutes(), fromDate.getUTCSeconds(), fromDate.getUTCMilliseconds());
return targetDate;
}
/**
* Calculate next monthly recurrence on last day of month
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @returns {Date} Next due date
*/
static calculateMonthlyLastDayRecurrence(fromDate, interval) {
const nextDate = new Date(fromDate);
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
// Set to last day of month
nextDate.setUTCMonth(nextDate.getUTCMonth() + 1, 0);
return nextDate;
}
/**
* Helper function to get first weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @returns {Date} First occurrence of weekday in month
*/
static _getFirstWeekdayOfMonth(year, month, weekday) {
const firstOfMonth = new Date(year, month, 1);
const firstWeekday = firstOfMonth.getDay();
const daysToAdd = (weekday - firstWeekday + 7) % 7;
return new Date(year, month, 1 + daysToAdd);
}
/**
* Helper function to get last weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @returns {Date} Last occurrence of weekday in month
*/
static _getLastWeekdayOfMonth(year, month, weekday) {
const lastOfMonth = new Date(year, month + 1, 0);
const lastWeekday = lastOfMonth.getDay();
const daysToSubtract = (lastWeekday - weekday + 7) % 7;
return new Date(year, month, lastOfMonth.getDate() - daysToSubtract);
}
/**
* Helper function to get nth weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @param {number} n - Which occurrence (1-5)
* @returns {Date} Nth occurrence of weekday in month
*/
static _getNthWeekdayOfMonth(year, month, weekday, n) {
const firstOccurrence = this._getFirstWeekdayOfMonth(year, month, weekday);
const targetDate = new Date(firstOccurrence);
targetDate.setDate(firstOccurrence.getDate() + ((n - 1) * 7));
// If target date is in next month, return null
if (targetDate.getMonth() !== month) {
return null;
}
return targetDate;
}
/**
* Helper function to check if next task should be generated
* @param {Object} task - The recurring task
* @param {Date} nextDate - Next due date
* @returns {boolean} Whether to generate next task
*/
static _shouldGenerateNextTask(task, nextDate) {
if (!task.recurrence_end_date) {
return true;
}
return nextDate < task.recurrence_end_date;
}
/**
* Handle task completion for recurring tasks
* @param {Object} task - The completed task
* @returns {Promise<Object|null>} Next task instance if applicable
*/
static async handleTaskCompletion(task) {
// Check if the completed task itself is a recurring task
if (!task.recurrence_type || task.recurrence_type === 'none') {
return null;
return targetDate;
}
// Only generate next task if completion_based is true
if (!task.completion_based) {
return null;
/**
* Calculate next monthly recurrence on last day of month
* @param {Date} fromDate - Starting date
* @param {number} interval - Months between recurrences
* @returns {Date} Next due date
*/
static calculateMonthlyLastDayRecurrence(fromDate, interval) {
const nextDate = new Date(fromDate);
nextDate.setUTCMonth(nextDate.getUTCMonth() + interval);
// Set to last day of month
nextDate.setUTCMonth(nextDate.getUTCMonth() + 1, 0);
return nextDate;
}
// Update the task's last generated date to completion date
task.last_generated_date = new Date();
await task.save();
// For completion-based tasks, create the next instance immediately
const nextDueDate = this.calculateNextDueDate(task, new Date());
if (!nextDueDate) {
return null;
/**
* Helper function to get first weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @returns {Date} First occurrence of weekday in month
*/
static _getFirstWeekdayOfMonth(year, month, weekday) {
const firstOfMonth = new Date(year, month, 1);
const firstWeekday = firstOfMonth.getDay();
const daysToAdd = (weekday - firstWeekday + 7) % 7;
return new Date(year, month, 1 + daysToAdd);
}
// Check if this due date already has a task instance
const whereClause = {
user_id: task.user_id,
name: task.name,
due_date: nextDueDate
};
// Only add project_id to where clause if it's not null/undefined
if (task.project_id !== null && task.project_id !== undefined) {
whereClause.project_id = task.project_id;
} else {
whereClause.project_id = null;
}
const existingTask = await Task.findOne({
where: whereClause
});
if (existingTask) {
return null; // Task already exists for this date
/**
* Helper function to get last weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @returns {Date} Last occurrence of weekday in month
*/
static _getLastWeekdayOfMonth(year, month, weekday) {
const lastOfMonth = new Date(year, month + 1, 0);
const lastWeekday = lastOfMonth.getDay();
const daysToSubtract = (lastWeekday - weekday + 7) % 7;
return new Date(year, month, lastOfMonth.getDate() - daysToSubtract);
}
// Create the next task instance
const nextTask = await this.createTaskInstance(task, nextDueDate);
return nextTask;
}
/**
* Helper function to get nth weekday of month
* @param {number} year - Year
* @param {number} month - Month (0-11)
* @param {number} weekday - Weekday (0=Sunday, 6=Saturday)
* @param {number} n - Which occurrence (1-5)
* @returns {Date} Nth occurrence of weekday in month
*/
static _getNthWeekdayOfMonth(year, month, weekday, n) {
const firstOccurrence = this._getFirstWeekdayOfMonth(
year,
month,
weekday
);
const targetDate = new Date(firstOccurrence);
targetDate.setDate(firstOccurrence.getDate() + (n - 1) * 7);
// If target date is in next month, return null
if (targetDate.getMonth() !== month) {
return null;
}
return targetDate;
}
/**
* Helper function to check if next task should be generated
* @param {Object} task - The recurring task
* @param {Date} nextDate - Next due date
* @returns {boolean} Whether to generate next task
*/
static _shouldGenerateNextTask(task, nextDate) {
if (!task.recurrence_end_date) {
return true;
}
return nextDate < task.recurrence_end_date;
}
/**
* Handle task completion for recurring tasks
* @param {Object} task - The completed task
* @returns {Promise<Object|null>} Next task instance if applicable
*/
static async handleTaskCompletion(task) {
// Check if the completed task itself is a recurring task
if (!task.recurrence_type || task.recurrence_type === 'none') {
return null;
}
// Only generate next task if completion_based is true
if (!task.completion_based) {
return null;
}
// Update the task's last generated date to completion date
task.last_generated_date = new Date();
await task.save();
// For completion-based tasks, create the next instance immediately
const nextDueDate = this.calculateNextDueDate(task, new Date());
if (!nextDueDate) {
return null;
}
// Check if this due date already has a task instance
const whereClause = {
user_id: task.user_id,
name: task.name,
due_date: nextDueDate,
};
// Only add project_id to where clause if it's not null/undefined
if (task.project_id !== null && task.project_id !== undefined) {
whereClause.project_id = task.project_id;
} else {
whereClause.project_id = null;
}
const existingTask = await Task.findOne({
where: whereClause,
});
if (existingTask) {
return null; // Task already exists for this date
}
// Create the next task instance
const nextTask = await this.createTaskInstance(task, nextDueDate);
return nextTask;
}
}
module.exports = RecurringTaskService;
module.exports = RecurringTaskService;

View file

@ -1,329 +1,417 @@
const { TaskEvent } = require('../models');
class TaskEventService {
/**
* Log a task event
* @param {Object} eventData - Event data
* @param {number} eventData.taskId - Task ID
* @param {number} eventData.userId - User ID
* @param {string} eventData.eventType - Type of event
* @param {string} eventData.fieldName - Field that changed (optional)
* @param {any} eventData.oldValue - Old value (optional)
* @param {any} eventData.newValue - New value (optional)
* @param {Object} eventData.metadata - Additional metadata (optional)
*/
static async logEvent({ taskId, userId, eventType, fieldName = null, oldValue = null, newValue = null, metadata = {} }) {
try {
// Add source to metadata if not provided
if (!metadata.source) {
metadata.source = 'web';
}
const event = await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: eventType,
field_name: fieldName,
old_value: oldValue ? { [fieldName || 'value']: oldValue } : null,
new_value: newValue ? { [fieldName || 'value']: newValue } : null,
metadata: metadata
});
return event;
} catch (error) {
console.error('Error logging task event:', error);
throw error;
}
}
/**
* Log task creation event
*/
static async logTaskCreated(taskId, userId, taskData, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'created',
newValue: taskData,
metadata: { ...metadata, action: 'task_created' }
});
}
/**
* Log status change event
*/
static async logStatusChange(taskId, userId, oldStatus, newStatus, metadata = {}) {
const eventType = newStatus === 2 ? 'completed' :
newStatus === 3 ? 'archived' :
'status_changed';
return await this.logEvent({
taskId,
userId,
eventType,
fieldName: 'status',
oldValue: oldStatus,
newValue: newStatus,
metadata: { ...metadata, action: 'status_change' }
});
}
/**
* Log priority change event
*/
static async logPriorityChange(taskId, userId, oldPriority, newPriority, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'priority_changed',
fieldName: 'priority',
oldValue: oldPriority,
newValue: newPriority,
metadata: { ...metadata, action: 'priority_change' }
});
}
/**
* Log due date change event
*/
static async logDueDateChange(taskId, userId, oldDueDate, newDueDate, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'due_date_changed',
fieldName: 'due_date',
oldValue: oldDueDate,
newValue: newDueDate,
metadata: { ...metadata, action: 'due_date_change' }
});
}
/**
* Log project change event
*/
static async logProjectChange(taskId, userId, oldProjectId, newProjectId, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'project_changed',
fieldName: 'project_id',
oldValue: oldProjectId,
newValue: newProjectId,
metadata: { ...metadata, action: 'project_change' }
});
}
/**
* Log task name change event
*/
static async logNameChange(taskId, userId, oldName, newName, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'name_changed',
fieldName: 'name',
oldValue: oldName,
newValue: newName,
metadata: { ...metadata, action: 'name_change' }
});
}
/**
* Log description change event
*/
static async logDescriptionChange(taskId, userId, oldDescription, newDescription, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'description_changed',
fieldName: 'description',
oldValue: oldDescription,
newValue: newDescription,
metadata: { ...metadata, action: 'description_change' }
});
}
/**
* Log multiple field changes at once
*/
static async logTaskUpdate(taskId, userId, changes, metadata = {}) {
const events = [];
for (const [fieldName, { oldValue, newValue }] of Object.entries(changes)) {
// Skip if values are the same
if (oldValue === newValue) continue;
let eventType;
switch (fieldName) {
case 'status':
eventType = newValue === 2 ? 'completed' :
newValue === 3 ? 'archived' :
'status_changed';
break;
default:
eventType = `${fieldName}_changed`;
}
const event = await this.logEvent({
/**
* Log a task event
* @param {Object} eventData - Event data
* @param {number} eventData.taskId - Task ID
* @param {number} eventData.userId - User ID
* @param {string} eventData.eventType - Type of event
* @param {string} eventData.fieldName - Field that changed (optional)
* @param {any} eventData.oldValue - Old value (optional)
* @param {any} eventData.newValue - New value (optional)
* @param {Object} eventData.metadata - Additional metadata (optional)
*/
static async logEvent({
taskId,
userId,
eventType,
fieldName,
oldValue,
newValue,
metadata: { ...metadata, action: 'bulk_update' }
});
fieldName = null,
oldValue = null,
newValue = null,
metadata = {},
}) {
try {
// Add source to metadata if not provided
if (!metadata.source) {
metadata.source = 'web';
}
events.push(event);
}
const event = await TaskEvent.create({
task_id: taskId,
user_id: userId,
event_type: eventType,
field_name: fieldName,
old_value: oldValue
? { [fieldName || 'value']: oldValue }
: null,
new_value: newValue
? { [fieldName || 'value']: newValue }
: null,
metadata: metadata,
});
return events;
}
/**
* Get task timeline (all events for a task)
*/
static async getTaskTimeline(taskId) {
return await TaskEvent.findAll({
where: { task_id: taskId },
order: [['created_at', 'ASC']],
include: [{
model: require('../models').User,
as: 'User',
attributes: ['id', 'name', 'email']
}]
});
}
/**
* Get task completion metrics
*/
static async getTaskCompletionTime(taskId) {
const events = await TaskEvent.findAll({
where: {
task_id: taskId,
event_type: ['status_changed', 'created', 'completed']
},
order: [['created_at', 'ASC']]
});
if (events.length === 0) return null;
// Find when task was started (moved to in_progress or created)
const startEvent = events.find(e =>
e.event_type === 'created' ||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
);
// Find when task was completed
const completedEvent = events.find(e =>
e.event_type === 'completed' ||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
);
if (!startEvent || !completedEvent) return null;
const startTime = new Date(startEvent.created_at);
const endTime = new Date(completedEvent.created_at);
return {
task_id: taskId,
started_at: startTime,
completed_at: endTime,
duration_ms: endTime - startTime,
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
duration_days: (endTime - startTime) / (1000 * 60 * 60 * 24)
};
}
/**
* Get user productivity metrics
*/
static async getUserProductivityMetrics(userId, startDate = null, endDate = null) {
const whereClause = { user_id: userId };
if (startDate && endDate) {
whereClause.created_at = {
[require('sequelize').Op.between]: [startDate, endDate]
};
}
const events = await TaskEvent.findAll({
where: whereClause,
order: [['created_at', 'ASC']]
});
// Calculate metrics
const metrics = {
total_events: events.length,
tasks_created: events.filter(e => e.event_type === 'created').length,
tasks_completed: events.filter(e => e.event_type === 'completed').length,
status_changes: events.filter(e => e.event_type === 'status_changed').length,
average_completion_time: null,
completion_times: []
};
// Calculate completion times for all completed tasks
const completedTasks = events.filter(e => e.event_type === 'completed');
const completionTimes = [];
for (const completedEvent of completedTasks) {
const taskCompletion = await this.getTaskCompletionTime(completedEvent.task_id);
if (taskCompletion) {
completionTimes.push(taskCompletion);
}
}
if (completionTimes.length > 0) {
const totalHours = completionTimes.reduce((sum, ct) => sum + ct.duration_hours, 0);
metrics.average_completion_time = totalHours / completionTimes.length;
metrics.completion_times = completionTimes;
}
return metrics;
}
/**
* Get task activity summary for a date range
*/
static async getTaskActivitySummary(userId, startDate, endDate) {
const events = await TaskEvent.findAll({
where: {
user_id: userId,
created_at: {
[require('sequelize').Op.between]: [startDate, endDate]
return event;
} catch (error) {
console.error('Error logging task event:', error);
throw error;
}
},
attributes: [
'event_type',
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
[require('sequelize').fn('DATE', require('sequelize').col('created_at')), 'date']
],
group: ['event_type', 'date'],
order: [['date', 'ASC']]
});
}
return events;
}
/**
* Log task creation event
*/
static async logTaskCreated(taskId, userId, taskData, metadata = {}) {
return await this.logEvent({
taskId,
userId,
eventType: 'created',
newValue: taskData,
metadata: { ...metadata, action: 'task_created' },
});
}
/**
* Get count of how many times a task has been moved to today
*/
static async getTaskTodayMoveCount(taskId) {
const { Op } = require('sequelize');
const count = await TaskEvent.count({
where: {
task_id: taskId,
event_type: 'today_changed',
new_value: {
[Op.like]: '%"today":true%'
/**
* Log status change event
*/
static async logStatusChange(
taskId,
userId,
oldStatus,
newStatus,
metadata = {}
) {
const eventType =
newStatus === 2
? 'completed'
: newStatus === 3
? 'archived'
: 'status_changed';
return await this.logEvent({
taskId,
userId,
eventType,
fieldName: 'status',
oldValue: oldStatus,
newValue: newStatus,
metadata: { ...metadata, action: 'status_change' },
});
}
/**
* Log priority change event
*/
static async logPriorityChange(
taskId,
userId,
oldPriority,
newPriority,
metadata = {}
) {
return await this.logEvent({
taskId,
userId,
eventType: 'priority_changed',
fieldName: 'priority',
oldValue: oldPriority,
newValue: newPriority,
metadata: { ...metadata, action: 'priority_change' },
});
}
/**
* Log due date change event
*/
static async logDueDateChange(
taskId,
userId,
oldDueDate,
newDueDate,
metadata = {}
) {
return await this.logEvent({
taskId,
userId,
eventType: 'due_date_changed',
fieldName: 'due_date',
oldValue: oldDueDate,
newValue: newDueDate,
metadata: { ...metadata, action: 'due_date_change' },
});
}
/**
* Log project change event
*/
static async logProjectChange(
taskId,
userId,
oldProjectId,
newProjectId,
metadata = {}
) {
return await this.logEvent({
taskId,
userId,
eventType: 'project_changed',
fieldName: 'project_id',
oldValue: oldProjectId,
newValue: newProjectId,
metadata: { ...metadata, action: 'project_change' },
});
}
/**
* Log task name change event
*/
static async logNameChange(
taskId,
userId,
oldName,
newName,
metadata = {}
) {
return await this.logEvent({
taskId,
userId,
eventType: 'name_changed',
fieldName: 'name',
oldValue: oldName,
newValue: newName,
metadata: { ...metadata, action: 'name_change' },
});
}
/**
* Log description change event
*/
static async logDescriptionChange(
taskId,
userId,
oldDescription,
newDescription,
metadata = {}
) {
return await this.logEvent({
taskId,
userId,
eventType: 'description_changed',
fieldName: 'description',
oldValue: oldDescription,
newValue: newDescription,
metadata: { ...metadata, action: 'description_change' },
});
}
/**
* Log multiple field changes at once
*/
static async logTaskUpdate(taskId, userId, changes, metadata = {}) {
const events = [];
for (const [fieldName, { oldValue, newValue }] of Object.entries(
changes
)) {
// Skip if values are the same
if (oldValue === newValue) continue;
let eventType;
switch (fieldName) {
case 'status':
eventType =
newValue === 2
? 'completed'
: newValue === 3
? 'archived'
: 'status_changed';
break;
default:
eventType = `${fieldName}_changed`;
}
const event = await this.logEvent({
taskId,
userId,
eventType,
fieldName,
oldValue,
newValue,
metadata: { ...metadata, action: 'bulk_update' },
});
events.push(event);
}
}
});
return count;
}
return events;
}
/**
* Get task timeline (all events for a task)
*/
static async getTaskTimeline(taskId) {
return await TaskEvent.findAll({
where: { task_id: taskId },
order: [['created_at', 'ASC']],
include: [
{
model: require('../models').User,
as: 'User',
attributes: ['id', 'name', 'email'],
},
],
});
}
/**
* Get task completion metrics
*/
static async getTaskCompletionTime(taskId) {
const events = await TaskEvent.findAll({
where: {
task_id: taskId,
event_type: ['status_changed', 'created', 'completed'],
},
order: [['created_at', 'ASC']],
});
if (events.length === 0) return null;
// Find when task was started (moved to in_progress or created)
const startEvent = events.find(
(e) =>
e.event_type === 'created' ||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
);
// Find when task was completed
const completedEvent = events.find(
(e) =>
e.event_type === 'completed' ||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
);
if (!startEvent || !completedEvent) return null;
const startTime = new Date(startEvent.created_at);
const endTime = new Date(completedEvent.created_at);
return {
task_id: taskId,
started_at: startTime,
completed_at: endTime,
duration_ms: endTime - startTime,
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
duration_days: (endTime - startTime) / (1000 * 60 * 60 * 24),
};
}
/**
* Get user productivity metrics
*/
static async getUserProductivityMetrics(
userId,
startDate = null,
endDate = null
) {
const whereClause = { user_id: userId };
if (startDate && endDate) {
whereClause.created_at = {
[require('sequelize').Op.between]: [startDate, endDate],
};
}
const events = await TaskEvent.findAll({
where: whereClause,
order: [['created_at', 'ASC']],
});
// Calculate metrics
const metrics = {
total_events: events.length,
tasks_created: events.filter((e) => e.event_type === 'created')
.length,
tasks_completed: events.filter((e) => e.event_type === 'completed')
.length,
status_changes: events.filter(
(e) => e.event_type === 'status_changed'
).length,
average_completion_time: null,
completion_times: [],
};
// Calculate completion times for all completed tasks
const completedTasks = events.filter(
(e) => e.event_type === 'completed'
);
const completionTimes = [];
for (const completedEvent of completedTasks) {
const taskCompletion = await this.getTaskCompletionTime(
completedEvent.task_id
);
if (taskCompletion) {
completionTimes.push(taskCompletion);
}
}
if (completionTimes.length > 0) {
const totalHours = completionTimes.reduce(
(sum, ct) => sum + ct.duration_hours,
0
);
metrics.average_completion_time =
totalHours / completionTimes.length;
metrics.completion_times = completionTimes;
}
return metrics;
}
/**
* Get task activity summary for a date range
*/
static async getTaskActivitySummary(userId, startDate, endDate) {
const events = await TaskEvent.findAll({
where: {
user_id: userId,
created_at: {
[require('sequelize').Op.between]: [startDate, endDate],
},
},
attributes: [
'event_type',
[
require('sequelize').fn(
'COUNT',
require('sequelize').col('id')
),
'count',
],
[
require('sequelize').fn(
'DATE',
require('sequelize').col('created_at')
),
'date',
],
],
group: ['event_type', 'date'],
order: [['date', 'ASC']],
});
return events;
}
/**
* Get count of how many times a task has been moved to today
*/
static async getTaskTodayMoveCount(taskId) {
const { Op } = require('sequelize');
const count = await TaskEvent.count({
where: {
task_id: taskId,
event_type: 'today_changed',
new_value: {
[Op.like]: '%"today":true%',
},
},
});
return count;
}
}
module.exports = TaskEventService;
module.exports = TaskEventService;

View file

@ -5,187 +5,197 @@ const RecurringTaskService = require('./recurringTaskService');
// Create scheduler state
const createSchedulerState = () => ({
jobs: new Map(),
isInitialized: false
jobs: new Map(),
isInitialized: false,
});
// 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';
const shouldDisableScheduler = () =>
process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true';
// Create job configuration
const createJobConfig = () => ({
scheduled: false,
timezone: 'UTC'
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 * * *',
recurring_tasks: '0 6 * * *' // Daily at 6 AM for recurring task generation
};
return expressions[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 * * *',
recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation
};
return expressions[frequency];
};
// Create job handler
const createJobHandler = (frequency) => async () => {
if (frequency === 'recurring_tasks') {
await processRecurringTasks();
} else {
await processSummariesForFrequency(frequency);
}
if (frequency === 'recurring_tasks') {
await processRecurringTasks();
} else {
await processSummariesForFrequency(frequency);
}
};
// Create job entries
const createJobEntries = () => {
const frequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h', 'recurring_tasks'];
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];
});
const frequencies = [
'daily',
'weekdays',
'weekly',
'1h',
'2h',
'4h',
'8h',
'12h',
'recurring_tasks',
];
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();
});
jobs.forEach((job, frequency) => {
job.start();
});
};
// Stop all jobs
const stopJobs = (jobs) => {
jobs.forEach((job, frequency) => {
job.stop();
});
jobs.forEach((job, frequency) => {
job.stop();
});
};
// 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 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,
},
});
};
// Side effect function to send summary to user
const sendSummaryToUser = async (userId, frequency) => {
try {
const success = await TaskSummaryService.sendSummaryToUser(userId);
return success;
} catch (error) {
return false;
}
try {
const success = await TaskSummaryService.sendSummaryToUser(userId);
return success;
} catch (error) {
return false;
}
};
// Function to process summaries for frequency (contains side effects)
const processSummariesForFrequency = async (frequency) => {
try {
const users = await fetchUsersForFrequency(frequency);
try {
const users = await fetchUsersForFrequency(frequency);
const results = await Promise.allSettled(
users.map(user => sendSummaryToUser(user.id, frequency))
);
const results = await Promise.allSettled(
users.map((user) => sendSummaryToUser(user.id, frequency))
);
return results;
} catch (error) {
throw error;
}
return results;
} catch (error) {
throw error;
}
};
// Function to process recurring tasks (contains side effects)
const processRecurringTasks = async () => {
try {
const newTasks = await RecurringTaskService.generateRecurringTasks();
return newTasks;
} catch (error) {
throw error;
}
try {
const newTasks = await RecurringTaskService.generateRecurringTasks();
return newTasks;
} catch (error) {
throw error;
}
};
// Function to initialize scheduler (contains side effects)
const initialize = async () => {
if (schedulerState.isInitialized) {
if (schedulerState.isInitialized) {
return schedulerState;
}
if (shouldDisableScheduler()) {
return schedulerState;
}
// Create job entries
const jobEntries = createJobEntries();
const jobs = new Map(jobEntries);
// Start all jobs
startJobs(jobs);
// Update state immutably
schedulerState = {
jobs,
isInitialized: true,
};
return schedulerState;
}
if (shouldDisableScheduler()) {
return schedulerState;
}
// Create job entries
const jobEntries = createJobEntries();
const jobs = new Map(jobEntries);
// Start all jobs
startJobs(jobs);
// Update state immutably
schedulerState = {
jobs,
isInitialized: true
};
return schedulerState;
};
// Function to stop scheduler (contains side effects)
const stop = async () => {
if (!schedulerState.isInitialized) {
return schedulerState;
}
// Stop all jobs
stopJobs(schedulerState.jobs);
if (!schedulerState.isInitialized) {
return schedulerState;
}
// Reset state immutably
schedulerState = createSchedulerState();
return schedulerState;
// Stop all jobs
stopJobs(schedulerState.jobs);
// Reset state immutably
schedulerState = createSchedulerState();
return schedulerState;
};
// Function to restart scheduler
const restart = async () => {
await stop();
return await initialize();
await stop();
return await initialize();
};
// Get scheduler status
const getStatus = () => ({
initialized: schedulerState.isInitialized,
jobCount: schedulerState.jobs.size,
jobs: Array.from(schedulerState.jobs.keys())
initialized: schedulerState.isInitialized,
jobCount: schedulerState.jobs.size,
jobs: Array.from(schedulerState.jobs.keys()),
});
// Export functional interface
module.exports = {
initialize,
stop,
restart,
getStatus,
processSummariesForFrequency,
processRecurringTasks,
// For testing
_createSchedulerState: createSchedulerState,
_shouldDisableScheduler: shouldDisableScheduler,
_getCronExpression: getCronExpression
};
initialize,
stop,
restart,
getStatus,
processSummariesForFrequency,
processRecurringTasks,
// For testing
_createSchedulerState: createSchedulerState,
_shouldDisableScheduler: shouldDisableScheduler,
_getCronExpression: getCronExpression,
};

View file

@ -4,283 +4,302 @@ const TelegramPoller = require('./telegramPoller');
// escape markdown special characters
const escapeMarkdown = (text) => {
if (!text) return '';
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
if (!text) return '';
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
};
// get priority emoji
const getPriorityEmoji = (priority) => {
const emojiMap = {
2: '🔴', // high
1: '🟠', // medium
0: '🟢' // low
};
return emojiMap[priority] || '⚪';
const emojiMap = {
2: '🔴', // high
1: '🟠', // medium
0: '🟢', // low
};
return emojiMap[priority] || '⚪';
};
// 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 };
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return { today, tomorrow };
};
// 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 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`;
};
// 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;
if (tasks.length === 0) return '';
let section = `${title}\n`;
section += tasks
.map((task, index) => formatTaskForDisplay(task, index, includeStatus))
.join('');
section += '\n';
return section;
};
// 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;
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;
};
// calculate next run time
const calculateNextRunTime = (user, fromTime = new Date()) => {
const frequency = user.task_summary_frequency;
const from = new Date(fromTime);
const frequency = user.task_summary_frequency;
const from = new Date(fromTime);
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
}
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;
}
};
const calculations = {
daily: () => {
const nextDay = new Date(from);
nextDay.setDate(nextDay.getDate() + 1);
nextDay.setHours(7, 0, 0, 0);
return nextDay;
},
const calculator = calculations[frequency];
return calculator ? calculator() : calculations.daily();
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
}
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;
},
};
const calculator = calculations[frequency];
return calculator ? calculator() : calculations.daily();
};
// Side effect function to fetch user by ID
const fetchUser = async (userId) =>
await User.findByPk(userId);
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']]
});
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']]
});
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 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']],
});
// 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
});
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,
});
// Side effect function to send telegram message
const sendTelegramMessage = async (token, chatId, message) => {
const poller = TelegramPoller;
return await poller.sendTelegramMessage(token, chatId, message);
const poller = TelegramPoller;
return await poller.sendTelegramMessage(token, chatId, message);
};
// 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
});
const updateUserTracking = async (user, lastRun, nextRun) =>
await user.update({
task_summary_last_run: lastRun,
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;
try {
const user = await fetchUser(userId);
if (!user) return null;
const { today, tomorrow } = createTodayDateRange();
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)
]);
// 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);
// 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)
};
// 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;
}
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;
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;
}
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;
}
};
// Export functional interface
module.exports = {
generateSummaryForUser,
sendSummaryToUser,
calculateNextRunTime,
// For testing
_escapeMarkdown: escapeMarkdown,
_getPriorityEmoji: getPriorityEmoji,
_createTodayDateRange: createTodayDateRange,
_formatTaskForDisplay: formatTaskForDisplay,
_buildTaskSection: buildTaskSection,
_buildSummaryMessage: buildSummaryMessage
};
generateSummaryForUser,
sendSummaryToUser,
calculateNextRunTime,
// For testing
_escapeMarkdown: escapeMarkdown,
_getPriorityEmoji: getPriorityEmoji,
_createTodayDateRange: createTodayDateRange,
_formatTaskForDisplay: formatTaskForDisplay,
_buildTaskSection: buildTaskSection,
_buildSummaryMessage: buildSummaryMessage,
};

View file

@ -2,29 +2,32 @@ const telegramPoller = require('./telegramPoller');
const { User } = require('../models');
async function initializeTelegramPolling() {
if (process.env.NODE_ENV === 'test' || process.env.DISABLE_TELEGRAM === 'true') {
return;
}
try {
// Find users with configured Telegram tokens
const usersWithTelegram = await User.findAll({
where: {
telegram_bot_token: {
[require('sequelize').Op.ne]: null
}
}
});
if (usersWithTelegram.length > 0) {
// Add each user to the polling list
for (const user of usersWithTelegram) {
await telegramPoller.addUser(user);
}
if (
process.env.NODE_ENV === 'test' ||
process.env.DISABLE_TELEGRAM === 'true'
) {
return;
}
try {
// Find users with configured Telegram tokens
const usersWithTelegram = await User.findAll({
where: {
telegram_bot_token: {
[require('sequelize').Op.ne]: null,
},
},
});
if (usersWithTelegram.length > 0) {
// Add each user to the polling list
for (const user of usersWithTelegram) {
await telegramPoller.addUser(user);
}
}
} catch (error) {
// Telegram polling will be initialized later when the database is available
}
} catch (error) {
// Telegram polling will be initialized later when the database is available
}
}
module.exports = { initializeTelegramPolling };
module.exports = { initializeTelegramPolling };

View file

@ -3,400 +3,422 @@ const { User, InboxItem } = require('../models');
// Create poller state
const createPollerState = () => ({
running: false,
interval: null,
pollInterval: 5000, // 5 seconds
usersToPool: [],
userStatus: {},
processedUpdates: new Set() // Track processed update IDs to prevent duplicates
running: false,
interval: null,
pollInterval: 5000, // 5 seconds
usersToPool: [],
userStatus: {},
processedUpdates: new Set(), // Track processed update IDs to prevent duplicates
});
// Global mutable state (managed functionally)
let pollerState = createPollerState();
// Check if user exists in list
const userExistsInList = (users, userId) =>
users.some(u => u.id === userId);
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];
if (userExistsInList(users, user.id)) {
return users;
}
return [...users, user];
};
// Remove user from list
const removeUserFromList = (users, userId) =>
users.filter(u => u.id !== userId);
const removeUserFromList = (users, userId) =>
users.filter((u) => u.id !== userId);
// Remove user status
const removeUserStatus = (userStatus, userId) => {
const { [userId]: removed, ...rest } = userStatus;
return rest;
const { [userId]: removed, ...rest } = userStatus;
return rest;
};
// Update user status
const updateUserStatus = (userStatus, userId, updates) => ({
...userStatus,
[userId]: {
...userStatus[userId],
...updates
}
...userStatus,
[userId]: {
...userStatus[userId],
...updates,
},
});
// Get highest update ID from updates
const getHighestUpdateId = (updates) => {
if (!updates.length) return 0;
return Math.max(...updates.map(u => u.update_id));
if (!updates.length) return 0;
return Math.max(...updates.map((u) => u.update_id));
};
// 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;
const params = { chat_id: chatId, text: text };
if (replyToMessageId) {
params.reply_to_message_id = replyToMessageId;
}
return params;
};
// 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}`;
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}`;
};
// 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 = '';
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'));
return new Promise((resolve, reject) => {
https
.get(url, { timeout }, (res) => {
let data = '';
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);
}
});
});
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);
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
req.on('error', reject);
req.write(postData);
req.end();
});
};
// Side effect function to get Telegram updates
const getTelegramUpdates = async (token, offset) => {
try {
const url = createTelegramUrl(token, 'getUpdates', {
offset: offset.toString(),
timeout: '1'
});
const response = await makeHttpGetRequest(url, 5000);
if (response.ok && Array.isArray(response.result)) {
return response.result;
} else {
return [];
try {
const url = createTelegramUrl(token, 'getUpdates', {
offset: offset.toString(),
timeout: '1',
});
const response = await makeHttpGetRequest(url, 5000);
if (response.ok && Array.isArray(response.result)) {
return response.result;
} else {
return [];
}
} catch (error) {
throw error;
}
} catch (error) {
throw error;
}
};
// 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)
}
};
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');
return await makeHttpPostRequest(url, postData, options);
} catch (error) {
throw error;
}
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 } }
);
await User.update({ telegram_chat_id: chatId }, { where: { id: userId } });
};
// Side effect function to create inbox item
const createInboxItem = async (content, userId, messageId) => {
// Check if a similar item was created recently (within last 30 seconds)
// to prevent duplicates from network issues or multiple processing
const recentCutoff = new Date(Date.now() - 30000); // 30 seconds ago
const existingItem = await InboxItem.findOne({
where: {
content: content,
user_id: userId,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
// Check if a similar item was created recently (within last 30 seconds)
// to prevent duplicates from network issues or multiple processing
const recentCutoff = new Date(Date.now() - 30000); // 30 seconds ago
const existingItem = await InboxItem.findOne({
where: {
content: content,
user_id: userId,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff,
},
},
});
if (existingItem) {
console.log(
`Duplicate inbox item detected for user ${userId}, content: "${content}". Skipping creation.`
);
return existingItem;
}
});
if (existingItem) {
console.log(`Duplicate inbox item detected for user ${userId}, content: "${content}". Skipping creation.`);
return existingItem;
}
return await InboxItem.create({
content: content,
source: 'telegram',
user_id: userId,
metadata: { telegram_message_id: messageId } // Store message ID for reference
});
return await InboxItem.create({
content: content,
source: 'telegram',
user_id: userId,
metadata: { telegram_message_id: messageId }, // Store message ID for reference
});
};
// 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;
const message = update.message;
const text = message.text;
const chatId = message.chat.id.toString();
const messageId = message.message_id;
// Update chat ID if needed
if (!user.telegram_chat_id) {
await updateUserChatId(user.id, chatId);
user.telegram_chat_id = chatId; // Update local object
}
// Update chat ID if needed
if (!user.telegram_chat_id) {
await updateUserChatId(user.id, chatId);
user.telegram_chat_id = chatId; // Update local object
}
try {
// Create inbox item (with duplicate check)
const inboxItem = await createInboxItem(text, user.id, messageId);
try {
// Create inbox item (with duplicate check)
const inboxItem = await createInboxItem(text, user.id, messageId);
// Send confirmation
await sendTelegramMessage(
user.telegram_bot_token,
chatId,
`✅ Added to Tududi inbox: "${text}"`,
messageId
);
console.log(`Successfully processed message ${messageId} for user ${user.id}: "${text}"`);
} catch (error) {
// Send error message
await sendTelegramMessage(
user.telegram_bot_token,
chatId,
`❌ Failed to add to inbox: ${error.message}`,
messageId
);
}
// Send confirmation
await sendTelegramMessage(
user.telegram_bot_token,
chatId,
`✅ Added to Tududi inbox: "${text}"`,
messageId
);
console.log(
`Successfully processed message ${messageId} for user ${user.id}: "${text}"`
);
} catch (error) {
// 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;
if (!updates.length) return;
// Filter out already processed updates
const newUpdates = updates.filter(update => {
const updateKey = `${user.id}-${update.update_id}`;
return !pollerState.processedUpdates.has(updateKey);
});
// Filter out already processed updates
const newUpdates = updates.filter((update) => {
const updateKey = `${user.id}-${update.update_id}`;
return !pollerState.processedUpdates.has(updateKey);
});
if (!newUpdates.length) return;
if (!newUpdates.length) return;
// Get highest update ID from new updates
const highestUpdateId = getHighestUpdateId(newUpdates);
// Update user status
pollerState = {
...pollerState,
userStatus: updateUserStatus(pollerState.userStatus, user.id, {
lastUpdateId: highestUpdateId
})
};
// Get highest update ID from new updates
const highestUpdateId = getHighestUpdateId(newUpdates);
// Process each new update
for (const update of newUpdates) {
try {
const updateKey = `${user.id}-${update.update_id}`;
if (update.message && update.message.text) {
await processMessage(user, update);
// Mark update as processed
pollerState.processedUpdates.add(updateKey);
// Clean up old processed updates (keep only last 1000 to prevent memory leak)
if (pollerState.processedUpdates.size > 1000) {
const oldestEntries = Array.from(pollerState.processedUpdates).slice(0, 100);
oldestEntries.forEach(entry => pollerState.processedUpdates.delete(entry));
// Update user status
pollerState = {
...pollerState,
userStatus: updateUserStatus(pollerState.userStatus, user.id, {
lastUpdateId: highestUpdateId,
}),
};
// Process each new update
for (const update of newUpdates) {
try {
const updateKey = `${user.id}-${update.update_id}`;
if (update.message && update.message.text) {
await processMessage(user, update);
// Mark update as processed
pollerState.processedUpdates.add(updateKey);
// Clean up old processed updates (keep only last 1000 to prevent memory leak)
if (pollerState.processedUpdates.size > 1000) {
const oldestEntries = Array.from(
pollerState.processedUpdates
).slice(0, 100);
oldestEntries.forEach((entry) =>
pollerState.processedUpdates.delete(entry)
);
}
}
} catch (error) {
console.error(
`Error processing update ${update.update_id} for user ${user.id}:`,
error
);
}
}
} catch (error) {
console.error(`Error processing update ${update.update_id} for user ${user.id}:`, error);
}
}
};
// 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;
for (const user of pollerState.usersToPool) {
const token = user.telegram_bot_token;
if (!token) continue;
try {
const lastUpdateId = pollerState.userStatus[user.id]?.lastUpdateId || 0;
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
if (updates && updates.length > 0) {
console.log(`Processing ${updates.length} updates for user ${user.id}, starting from update ID ${lastUpdateId + 1}`);
await processUpdates(user, updates);
}
} catch (error) {
console.error(`Error getting updates for user ${user.id}:`, error);
try {
const lastUpdateId =
pollerState.userStatus[user.id]?.lastUpdateId || 0;
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
if (updates && updates.length > 0) {
console.log(
`Processing ${updates.length} updates for user ${user.id}, starting from update ID ${lastUpdateId + 1}`
);
await processUpdates(user, updates);
}
} catch (error) {
console.error(`Error getting updates for user ${user.id}:`, error);
}
}
}
};
// Function to start polling (contains side effects)
const startPolling = () => {
if (pollerState.running) return;
const interval = setInterval(async () => {
try {
await pollUpdates();
} catch (error) {
// Error polling Telegram
}
}, pollerState.pollInterval);
if (pollerState.running) return;
pollerState = {
...pollerState,
running: true,
interval
};
const interval = setInterval(async () => {
try {
await pollUpdates();
} catch (error) {
// Error polling Telegram
}
}, pollerState.pollInterval);
pollerState = {
...pollerState,
running: true,
interval,
};
};
// Function to stop polling (contains side effects)
const stopPolling = () => {
if (!pollerState.running) return;
if (pollerState.interval) {
clearInterval(pollerState.interval);
}
if (!pollerState.running) return;
pollerState = {
...pollerState,
running: false,
interval: null
};
if (pollerState.interval) {
clearInterval(pollerState.interval);
}
pollerState = {
...pollerState,
running: false,
interval: null,
};
};
// Function to add user (contains side effects)
const addUser = async (user) => {
if (!user || !user.telegram_bot_token) {
return false;
}
if (!user || !user.telegram_bot_token) {
return false;
}
// Add user to list
const newUsersList = addUserToList(pollerState.usersToPool, user);
pollerState = {
...pollerState,
usersToPool: newUsersList
};
// Add user to list
const newUsersList = addUserToList(pollerState.usersToPool, user);
// Start polling if not already running and we have users
if (pollerState.usersToPool.length > 0 && !pollerState.running) {
startPolling();
}
pollerState = {
...pollerState,
usersToPool: newUsersList,
};
return true;
// 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);
// Remove user from list and status
const newUsersList = removeUserFromList(pollerState.usersToPool, userId);
const newUserStatus = removeUserStatus(pollerState.userStatus, userId);
pollerState = {
...pollerState,
usersToPool: newUsersList,
userStatus: newUserStatus
};
pollerState = {
...pollerState,
usersToPool: newUsersList,
userStatus: newUserStatus,
};
// Stop polling if no users left
if (pollerState.usersToPool.length === 0 && pollerState.running) {
stopPolling();
}
// Stop polling if no users left
if (pollerState.usersToPool.length === 0 && pollerState.running) {
stopPolling();
}
return true;
return true;
};
// Get poller status
const getStatus = () => ({
running: pollerState.running,
usersCount: pollerState.usersToPool.length,
pollInterval: pollerState.pollInterval,
userStatus: pollerState.userStatus
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
};
addUser,
removeUser,
startPolling,
stopPolling,
getStatus,
sendTelegramMessage,
// For testing
_createPollerState: createPollerState,
_userExistsInList: userExistsInList,
_addUserToList: addUserToList,
_removeUserFromList: removeUserFromList,
_getHighestUpdateId: getHighestUpdateId,
_createMessageParams: createMessageParams,
_createTelegramUrl: createTelegramUrl,
};

View file

@ -18,26 +18,31 @@ tests/
## Running Tests
### All Tests
```bash
npm test
```
### Unit Tests Only
```bash
npm run test:unit
```
### Integration Tests Only
```bash
npm run test:integration
```
### Watch Mode (for development)
```bash
npm run test:watch
```
### Coverage Report
```bash
npm run test:coverage
```
@ -45,6 +50,7 @@ npm run test:coverage
## Test Environment
Tests run in a separate test environment with:
- In-memory SQLite database (isolated from development data)
- Test-specific configuration from `.env.test`
- Automatic database cleanup between tests
@ -52,17 +58,20 @@ Tests run in a separate test environment with:
## Writing Tests
### Unit Tests
- Test individual functions, models, or middleware in isolation
- Mock external dependencies
- Focus on business logic and edge cases
### Integration Tests
- Test complete API endpoints
- Use authenticated requests where needed
- Test real database interactions
- Verify response formats and status codes
### Test Utilities
- `tests/helpers/testUtils.js` provides utilities for creating test data
- `tests/helpers/setup.js` handles database setup and cleanup
- Use `createTestUser()` for creating authenticated test users
@ -79,4 +88,4 @@ Tests run in a separate test environment with:
- **Jest**: Test framework
- **Supertest**: HTTP testing library for integration tests
- **cross-env**: Cross-platform environment variable setting
- **cross-env**: Cross-platform environment variable setting

View file

@ -6,37 +6,43 @@ const fs = require('fs');
const path = require('path');
beforeAll(async () => {
// Ensure test database is clean and created
await sequelize.sync({ force: true });
// Ensure test database is clean and created
await sequelize.sync({ force: true });
}, 30000);
beforeEach(async () => {
// Clean all tables except Sessions to avoid conflicts
try {
const models = Object.values(sequelize.models);
const nonSessionModels = models.filter(model => model.name !== 'Session');
await Promise.all(nonSessionModels.map(model => model.destroy({ truncate: true, cascade: true })));
} catch (error) {
// Ignore errors during cleanup
}
// Clean all tables except Sessions to avoid conflicts
try {
const models = Object.values(sequelize.models);
const nonSessionModels = models.filter(
(model) => model.name !== 'Session'
);
await Promise.all(
nonSessionModels.map((model) =>
model.destroy({ truncate: true, cascade: true })
)
);
} catch (error) {
// Ignore errors during cleanup
}
});
afterEach(async () => {
// Clean up sessions after each test
try {
const Session = sequelize.models.Session;
if (Session) {
await Session.destroy({ truncate: true });
// Clean up sessions after each test
try {
const Session = sequelize.models.Session;
if (Session) {
await Session.destroy({ truncate: true });
}
} catch (error) {
// Ignore errors during session cleanup
}
} catch (error) {
// Ignore errors during session cleanup
}
});
afterAll(async () => {
try {
await sequelize.close();
} catch (error) {
// Database may already be closed
}
}, 30000);
try {
await sequelize.close();
} catch (error) {
// Database may already be closed
}
}, 30000);

View file

@ -2,27 +2,25 @@ const bcrypt = require('bcrypt');
const { User } = require('../../models');
const createTestUser = async (userData = {}) => {
const defaultUser = {
email: 'test@example.com',
password: 'password123', // Use password field to trigger model hook
...userData
};
return await User.create(defaultUser);
const defaultUser = {
email: 'test@example.com',
password: 'password123', // Use password field to trigger model hook
...userData,
};
return await User.create(defaultUser);
};
const authenticateUser = async (request, user) => {
const response = await request
.post('/api/login')
.send({
email: user.email,
password: 'password123'
const response = await request.post('/api/login').send({
email: user.email,
password: 'password123',
});
return response.headers['set-cookie'];
return response.headers['set-cookie'];
};
module.exports = {
createTestUser,
authenticateUser
};
createTestUser,
authenticateUser,
};

View file

@ -4,277 +4,275 @@ const { Area, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Areas Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/areas', () => {
it('should create a new area', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects'
};
const response = await agent
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(areaData.name);
expect(response.body.description).toBe(areaData.description);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const areaData = {
name: 'Work'
};
const response = await request(app)
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require area name', async () => {
const areaData = {
description: 'Area without name'
};
const response = await agent
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Area name is required.');
});
});
describe('GET /api/areas', () => {
let area1, area2;
let user, agent;
beforeEach(async () => {
area1 = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
user = await createTestUser({
email: 'test@example.com',
});
area2 = await Area.create({
name: 'Personal',
description: 'Personal projects',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user areas', async () => {
const response = await agent.get('/api/areas');
describe('POST /api/areas', () => {
it('should create a new area', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects',
};
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map(a => a.id)).toContain(area1.id);
expect(response.body.map(a => a.id)).toContain(area2.id);
const response = await agent.post('/api/areas').send(areaData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(areaData.name);
expect(response.body.description).toBe(areaData.description);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const areaData = {
name: 'Work',
};
const response = await request(app)
.post('/api/areas')
.send(areaData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require area name', async () => {
const areaData = {
description: 'Area without name',
};
const response = await agent.post('/api/areas').send(areaData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Area name is required.');
});
});
it('should order areas by name', async () => {
const response = await agent.get('/api/areas');
describe('GET /api/areas', () => {
let area1, area2;
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('Personal'); // P comes before W
expect(response.body[1].name).toBe('Work');
beforeEach(async () => {
area1 = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id,
});
area2 = await Area.create({
name: 'Personal',
description: 'Personal projects',
user_id: user.id,
});
});
it('should get all user areas', async () => {
const response = await agent.get('/api/areas');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map((a) => a.id)).toContain(area1.id);
expect(response.body.map((a) => a.id)).toContain(area2.id);
});
it('should order areas by name', async () => {
const response = await agent.get('/api/areas');
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('Personal'); // P comes before W
expect(response.body[1].name).toBe('Work');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/areas');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/areas');
describe('GET /api/areas/:id', () => {
let area;
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id,
});
});
describe('GET /api/areas/:id', () => {
let area;
it('should get area by id', async () => {
const response = await agent.get(`/api/areas/${area.id}`);
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
expect(response.status).toBe(200);
expect(response.body.id).toBe(area.id);
expect(response.body.name).toBe(area.name);
expect(response.body.description).toBe(area.description);
});
it('should return 404 for non-existent area', async () => {
const response = await agent.get('/api/areas/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe(
"Area not found or doesn't belong to the current user."
);
});
it("should not allow access to other user's areas", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id,
});
const response = await agent.get(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe(
"Area not found or doesn't belong to the current user."
);
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should get area by id', async () => {
const response = await agent.get(`/api/areas/${area.id}`);
describe('PATCH /api/areas/:id', () => {
let area;
expect(response.status).toBe(200);
expect(response.body.id).toBe(area.id);
expect(response.body.name).toBe(area.name);
expect(response.body.description).toBe(area.description);
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id,
});
});
it('should update area', async () => {
const updateData = {
name: 'Updated Work',
description: 'Updated description',
};
const response = await agent
.patch(`/api/areas/${area.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
});
it('should return 404 for non-existent area', async () => {
const response = await agent
.patch('/api/areas/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it("should not allow updating other user's areas", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id,
});
const response = await agent
.patch(`/api/areas/${otherArea.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/areas/${area.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent area', async () => {
const response = await agent.get('/api/areas/999999');
describe('DELETE /api/areas/:id', () => {
let area;
expect(response.status).toBe(404);
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
beforeEach(async () => {
area = await Area.create({
name: 'Work',
user_id: user.id,
});
});
it('should delete area', async () => {
const response = await agent.delete(`/api/areas/${area.id}`);
expect(response.status).toBe(204);
// Verify area is deleted
const deletedArea = await Area.findByPk(area.id);
expect(deletedArea).toBeNull();
});
it('should return 404 for non-existent area', async () => {
const response = await agent.delete('/api/areas/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it("should not allow deleting other user's areas", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id,
});
const response = await agent.delete(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should not allow access to other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent.get(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe("Area not found or doesn't belong to the current user.");
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/areas/:id', () => {
let area;
beforeEach(async () => {
area = await Area.create({
name: 'Work',
description: 'Work projects',
user_id: user.id
});
});
it('should update area', async () => {
const updateData = {
name: 'Updated Work',
description: 'Updated description'
};
const response = await agent
.patch(`/api/areas/${area.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
});
it('should return 404 for non-existent area', async () => {
const response = await agent
.patch('/api/areas/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should not allow updating other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/areas/${otherArea.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/areas/${area.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/areas/:id', () => {
let area;
beforeEach(async () => {
area = await Area.create({
name: 'Work',
user_id: user.id
});
});
it('should delete area', async () => {
const response = await agent.delete(`/api/areas/${area.id}`);
expect(response.status).toBe(204);
// Verify area is deleted
const deletedArea = await Area.findByPk(area.id);
expect(deletedArea).toBeNull();
});
it('should return 404 for non-existent area', async () => {
const response = await agent.delete('/api/areas/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should not allow deleting other user\'s areas', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherArea = await Area.create({
name: 'Other Area',
user_id: otherUser.id
});
const response = await agent.delete(`/api/areas/${otherArea.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Area not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/areas/${area.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});

View file

@ -4,152 +4,138 @@ const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Auth Routes', () => {
describe('POST /api/login', () => {
let user;
describe('POST /api/login', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
});
it('should login with valid credentials', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
});
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
expect(response.body.user.language).toBe('en');
expect(response.body.user.appearance).toBe('light');
expect(response.body.user.timezone).toBe('UTC');
});
it('should login with valid credentials', async () => {
const response = await request(app).post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
it('should return 400 for missing email', async () => {
const response = await request(app)
.post('/api/login')
.send({
password: 'password123'
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
expect(response.body.user.language).toBe('en');
expect(response.body.user.appearance).toBe('light');
expect(response.body.user.timezone).toBe('UTC');
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
it('should return 400 for missing email', async () => {
const response = await request(app).post('/api/login').send({
password: 'password123',
});
it('should return 400 for missing password', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com'
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
it('should return 400 for missing password', async () => {
const response = await request(app).post('/api/login').send({
email: 'test@example.com',
});
it('should return 401 for non-existent user', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'nonexistent@example.com',
password: 'password123'
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid login parameters.');
});
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
it('should return 401 for non-existent user', async () => {
const response = await request(app).post('/api/login').send({
email: 'nonexistent@example.com',
password: 'password123',
});
it('should return 401 for invalid password', async () => {
const response = await request(app)
.post('/api/login')
.send({
email: 'test@example.com',
password: 'wrongpassword'
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
});
it('should return 401 for invalid password', async () => {
const response = await request(app).post('/api/login').send({
email: 'test@example.com',
password: 'wrongpassword',
});
describe('GET /api/current_user', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
expect(response.status).toBe(401);
expect(response.body.errors).toEqual(['Invalid credentials']);
});
});
it('should return current user when logged in', async () => {
const agent = request.agent(app);
// Login first
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
describe('GET /api/current_user', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
});
// Check current user
const response = await agent.get('/api/current_user');
it('should return current user when logged in', async () => {
const agent = request.agent(app);
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
});
// Login first
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
it('should return null user when not logged in', async () => {
const response = await request(app).get('/api/current_user');
// Check current user
const response = await agent.get('/api/current_user');
expect(response.status).toBe(200);
expect(response.body.user).toBeNull();
});
});
describe('GET /api/logout', () => {
let user;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
});
it('should logout successfully', async () => {
const agent = request.agent(app);
// Login first
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.email).toBe('test@example.com');
expect(response.body.user.id).toBe(user.id);
});
// Logout
const response = await agent.get('/api/logout');
it('should return null user when not logged in', async () => {
const response = await request(app).get('/api/current_user');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
// Verify user is logged out
const currentUserResponse = await agent.get('/api/current_user');
expect(currentUserResponse.body.user).toBeNull();
expect(response.status).toBe(200);
expect(response.body.user).toBeNull();
});
});
it('should handle logout when not logged in', async () => {
const response = await request(app).get('/api/logout');
describe('GET /api/logout', () => {
let user;
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
});
it('should logout successfully', async () => {
const agent = request.agent(app);
// Login first
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
// Logout
const response = await agent.get('/api/logout');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
// Verify user is logged out
const currentUserResponse = await agent.get('/api/current_user');
expect(currentUserResponse.body.user).toBeNull();
});
it('should handle logout when not logged in', async () => {
const response = await request(app).get('/api/logout');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Logged out successfully');
});
});
});
});
});

View file

@ -4,345 +4,349 @@ const { InboxItem, Tag } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Inbox Routes - No Tags Scenario', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
// Ensure no tags exist for this user (clean slate)
await Tag.destroy({ where: { user_id: user.id } });
});
describe('GET /api/inbox - No Tags Scenario', () => {
it('should return empty inbox when no items exist and no tags exist', async () => {
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
it('should return inbox items even when no tags exist in system', async () => {
// Create inbox items without any tags in the system
const inboxItem1 = await InboxItem.create({
content: 'Test item without tags',
status: 'added',
user_id: user.id
});
const inboxItem2 = await InboxItem.create({
content: 'Another item without tags',
status: 'added',
user_id: user.id
});
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
expect(response.body.map(item => item.id)).toContain(inboxItem1.id);
expect(response.body.map(item => item.id)).toContain(inboxItem2.id);
expect(response.body[0].content).toBeDefined();
expect(response.body[0].status).toBe('added');
expect(response.body[0].user_id).toBe(user.id);
});
it('should handle mixed inbox items when no tags exist', async () => {
// Create inbox items with different statuses
const addedItem = await InboxItem.create({
content: 'Added item',
status: 'added',
user_id: user.id
});
await InboxItem.create({
content: 'Processed item',
status: 'processed',
user_id: user.id
});
await InboxItem.create({
content: 'Deleted item',
status: 'deleted',
user_id: user.id
});
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(response.body.length).toBe(1); // Only 'added' items should be returned
expect(response.body[0].id).toBe(addedItem.id);
expect(response.body[0].status).toBe('added');
});
});
describe('GET /api/tags - No Tags Scenario', () => {
it('should return empty array when no tags exist', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
it('should not affect inbox functionality when tags endpoint returns empty', async () => {
// Create inbox item
await InboxItem.create({
content: 'Test item',
status: 'added',
user_id: user.id
});
// Verify tags endpoint returns empty
const tagsResponse = await agent.get('/api/tags');
expect(tagsResponse.status).toBe(200);
expect(tagsResponse.body.length).toBe(0);
// Verify inbox still works
const inboxResponse = await agent.get('/api/inbox');
expect(inboxResponse.status).toBe(200);
expect(inboxResponse.body.length).toBe(1);
expect(inboxResponse.body[0].content).toBe('Test item');
});
});
describe('POST /api/inbox - No Tags Scenario', () => {
it('should create inbox items successfully when no tags exist', async () => {
const inboxData = {
content: 'New inbox item without tags',
source: 'web'
};
const response = await agent
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(201);
expect(response.body.content).toBe(inboxData.content);
expect(response.body.source).toBe(inboxData.source);
expect(response.body.status).toBe('added');
expect(response.body.user_id).toBe(user.id);
});
it('should handle multiple inbox items creation when no tags exist', async () => {
const items = [
{ content: 'First item', source: 'web' },
{ content: 'Second item', source: 'telegram' },
{ content: 'Third item', source: 'api' }
];
for (const item of items) {
const response = await agent
.post('/api/inbox')
.send(item);
expect(response.status).toBe(201);
expect(response.body.content).toBe(item.content);
expect(response.body.source).toBe(item.source);
}
// Verify all items are retrievable
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(3);
});
});
describe('PATCH /api/inbox/:id - No Tags Scenario', () => {
let inboxItem;
let user, agent;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Original content',
status: 'added',
user_id: user.id
});
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
// Ensure no tags exist for this user (clean slate)
await Tag.destroy({ where: { user_id: user.id } });
});
it('should update inbox items when no tags exist', async () => {
const updateData = {
content: 'Updated content without tags',
status: 'processed'
};
describe('GET /api/inbox - No Tags Scenario', () => {
it('should return empty inbox when no items exist and no tags exist', async () => {
const response = await agent.get('/api/inbox');
const response = await agent
.patch(`/api/inbox/${inboxItem.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
expect(response.status).toBe(200);
expect(response.body.content).toBe(updateData.content);
expect(response.body.status).toBe(updateData.status);
});
});
it('should return inbox items even when no tags exist in system', async () => {
// Create inbox items without any tags in the system
const inboxItem1 = await InboxItem.create({
content: 'Test item without tags',
status: 'added',
user_id: user.id,
});
describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => {
let inboxItem;
const inboxItem2 = await InboxItem.create({
content: 'Another item without tags',
status: 'added',
user_id: user.id,
});
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Item to process',
status: 'added',
user_id: user.id
});
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
expect(response.body.map((item) => item.id)).toContain(
inboxItem1.id
);
expect(response.body.map((item) => item.id)).toContain(
inboxItem2.id
);
expect(response.body[0].content).toBeDefined();
expect(response.body[0].status).toBe('added');
expect(response.body[0].user_id).toBe(user.id);
});
it('should handle mixed inbox items when no tags exist', async () => {
// Create inbox items with different statuses
const addedItem = await InboxItem.create({
content: 'Added item',
status: 'added',
user_id: user.id,
});
await InboxItem.create({
content: 'Processed item',
status: 'processed',
user_id: user.id,
});
await InboxItem.create({
content: 'Deleted item',
status: 'deleted',
user_id: user.id,
});
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(response.body.length).toBe(1); // Only 'added' items should be returned
expect(response.body[0].id).toBe(addedItem.id);
expect(response.body[0].status).toBe('added');
});
});
it('should process inbox items when no tags exist', async () => {
const response = await agent.patch(`/api/inbox/${inboxItem.id}/process`);
describe('GET /api/tags - No Tags Scenario', () => {
it('should return empty array when no tags exist', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
});
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(0);
});
describe('DELETE /api/inbox/:id - No Tags Scenario', () => {
let inboxItem;
it('should not affect inbox functionality when tags endpoint returns empty', async () => {
// Create inbox item
await InboxItem.create({
content: 'Test item',
status: 'added',
user_id: user.id,
});
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Item to delete',
status: 'added',
user_id: user.id
});
// Verify tags endpoint returns empty
const tagsResponse = await agent.get('/api/tags');
expect(tagsResponse.status).toBe(200);
expect(tagsResponse.body.length).toBe(0);
// Verify inbox still works
const inboxResponse = await agent.get('/api/inbox');
expect(inboxResponse.status).toBe(200);
expect(inboxResponse.body.length).toBe(1);
expect(inboxResponse.body[0].content).toBe('Test item');
});
});
it('should delete inbox items when no tags exist', async () => {
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
describe('POST /api/inbox - No Tags Scenario', () => {
it('should create inbox items successfully when no tags exist', async () => {
const inboxData = {
content: 'New inbox item without tags',
source: 'web',
};
expect(response.status).toBe(200);
expect(response.body.message).toBe('Inbox item successfully deleted');
const response = await agent.post('/api/inbox').send(inboxData);
// Verify item status is updated to deleted
const deletedItem = await InboxItem.findByPk(inboxItem.id);
expect(deletedItem).not.toBeNull();
expect(deletedItem.status).toBe('deleted');
});
});
expect(response.status).toBe(201);
expect(response.body.content).toBe(inboxData.content);
expect(response.body.source).toBe(inboxData.source);
expect(response.body.status).toBe('added');
expect(response.body.user_id).toBe(user.id);
});
describe('Full Workflow - No Tags Scenario', () => {
it('should support complete inbox workflow without any tags in system', async () => {
// Step 1: Verify no tags exist
const tagsResponse = await agent.get('/api/tags');
expect(tagsResponse.status).toBe(200);
expect(tagsResponse.body.length).toBe(0);
it('should handle multiple inbox items creation when no tags exist', async () => {
const items = [
{ content: 'First item', source: 'web' },
{ content: 'Second item', source: 'telegram' },
{ content: 'Third item', source: 'api' },
];
// Step 2: Create inbox item
const createResponse = await agent
.post('/api/inbox')
.send({ content: 'Complete workflow test', source: 'web' });
expect(createResponse.status).toBe(201);
const itemId = createResponse.body.id;
for (const item of items) {
const response = await agent.post('/api/inbox').send(item);
// Step 3: Retrieve inbox items
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(1);
expect(getResponse.body[0].id).toBe(itemId);
expect(response.status).toBe(201);
expect(response.body.content).toBe(item.content);
expect(response.body.source).toBe(item.source);
}
// Step 4: Update inbox item
const updateResponse = await agent
.patch(`/api/inbox/${itemId}`)
.send({ content: 'Updated workflow test' });
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.content).toBe('Updated workflow test');
// Step 5: Process inbox item
const processResponse = await agent.patch(`/api/inbox/${itemId}/process`);
expect(processResponse.status).toBe(200);
expect(processResponse.body.status).toBe('processed');
// Step 6: Verify processed item is not in main inbox list
const finalGetResponse = await agent.get('/api/inbox');
expect(finalGetResponse.status).toBe(200);
expect(finalGetResponse.body.length).toBe(0); // Processed items don't appear in inbox
// Verify all items are retrievable
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(3);
});
});
it('should handle concurrent operations when no tags exist', async () => {
// Create multiple items concurrently
const createPromises = Array.from({ length: 5 }, (_, i) =>
agent.post('/api/inbox').send({
content: `Concurrent item ${i + 1}`,
source: 'test'
})
);
describe('PATCH /api/inbox/:id - No Tags Scenario', () => {
let inboxItem;
const createResponses = await Promise.all(createPromises);
// All should succeed
createResponses.forEach(response => {
expect(response.status).toBe(201);
});
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Original content',
status: 'added',
user_id: user.id,
});
});
// Verify all items are retrievable
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(5);
it('should update inbox items when no tags exist', async () => {
const updateData = {
content: 'Updated content without tags',
status: 'processed',
};
// Process all items concurrently
const itemIds = createResponses.map(response => response.body.id);
const processPromises = itemIds.map(id =>
agent.patch(`/api/inbox/${id}/process`)
);
const response = await agent
.patch(`/api/inbox/${inboxItem.id}`)
.send(updateData);
const processResponses = await Promise.all(processPromises);
// All should succeed
processResponses.forEach(response => {
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
// Verify no items remain in inbox
const finalGetResponse = await agent.get('/api/inbox');
expect(finalGetResponse.status).toBe(200);
expect(finalGetResponse.body.length).toBe(0);
});
});
describe('Error Handling - No Tags Scenario', () => {
it('should handle invalid inbox item operations gracefully when no tags exist', async () => {
// Try to get non-existent item
const getResponse = await agent.get('/api/inbox/999999');
expect(getResponse.status).toBe(404);
expect(getResponse.body.error).toBe('Inbox item not found.');
// Try to update non-existent item
const updateResponse = await agent
.patch('/api/inbox/999999')
.send({ content: 'Updated' });
expect(updateResponse.status).toBe(404);
expect(updateResponse.body.error).toBe('Inbox item not found.');
// Try to process non-existent item
const processResponse = await agent.patch('/api/inbox/999999/process');
expect(processResponse.status).toBe(404);
expect(processResponse.body.error).toBe('Inbox item not found.');
// Try to delete non-existent item
const deleteResponse = await agent.delete('/api/inbox/999999');
expect(deleteResponse.status).toBe(404);
expect(deleteResponse.body.error).toBe('Inbox item not found.');
expect(response.status).toBe(200);
expect(response.body.content).toBe(updateData.content);
expect(response.body.status).toBe(updateData.status);
});
});
it('should validate required fields when creating inbox items (no tags scenario)', async () => {
// Try to create item without content
const response = await agent
.post('/api/inbox')
.send({});
describe('PATCH /api/inbox/:id/process - No Tags Scenario', () => {
let inboxItem;
expect(response.status).toBe(400);
expect(response.body.error).toBe('Content is required');
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Item to process',
status: 'added',
user_id: user.id,
});
});
it('should process inbox items when no tags exist', async () => {
const response = await agent.patch(
`/api/inbox/${inboxItem.id}/process`
);
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
});
});
});
describe('DELETE /api/inbox/:id - No Tags Scenario', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Item to delete',
status: 'added',
user_id: user.id,
});
});
it('should delete inbox items when no tags exist', async () => {
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe(
'Inbox item successfully deleted'
);
// Verify item status is updated to deleted
const deletedItem = await InboxItem.findByPk(inboxItem.id);
expect(deletedItem).not.toBeNull();
expect(deletedItem.status).toBe('deleted');
});
});
describe('Full Workflow - No Tags Scenario', () => {
it('should support complete inbox workflow without any tags in system', async () => {
// Step 1: Verify no tags exist
const tagsResponse = await agent.get('/api/tags');
expect(tagsResponse.status).toBe(200);
expect(tagsResponse.body.length).toBe(0);
// Step 2: Create inbox item
const createResponse = await agent
.post('/api/inbox')
.send({ content: 'Complete workflow test', source: 'web' });
expect(createResponse.status).toBe(201);
const itemId = createResponse.body.id;
// Step 3: Retrieve inbox items
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(1);
expect(getResponse.body[0].id).toBe(itemId);
// Step 4: Update inbox item
const updateResponse = await agent
.patch(`/api/inbox/${itemId}`)
.send({ content: 'Updated workflow test' });
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.content).toBe('Updated workflow test');
// Step 5: Process inbox item
const processResponse = await agent.patch(
`/api/inbox/${itemId}/process`
);
expect(processResponse.status).toBe(200);
expect(processResponse.body.status).toBe('processed');
// Step 6: Verify processed item is not in main inbox list
const finalGetResponse = await agent.get('/api/inbox');
expect(finalGetResponse.status).toBe(200);
expect(finalGetResponse.body.length).toBe(0); // Processed items don't appear in inbox
});
it('should handle concurrent operations when no tags exist', async () => {
// Create multiple items concurrently
const createPromises = Array.from({ length: 5 }, (_, i) =>
agent.post('/api/inbox').send({
content: `Concurrent item ${i + 1}`,
source: 'test',
})
);
const createResponses = await Promise.all(createPromises);
// All should succeed
createResponses.forEach((response) => {
expect(response.status).toBe(201);
});
// Verify all items are retrievable
const getResponse = await agent.get('/api/inbox');
expect(getResponse.status).toBe(200);
expect(getResponse.body.length).toBe(5);
// Process all items concurrently
const itemIds = createResponses.map((response) => response.body.id);
const processPromises = itemIds.map((id) =>
agent.patch(`/api/inbox/${id}/process`)
);
const processResponses = await Promise.all(processPromises);
// All should succeed
processResponses.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
// Verify no items remain in inbox
const finalGetResponse = await agent.get('/api/inbox');
expect(finalGetResponse.status).toBe(200);
expect(finalGetResponse.body.length).toBe(0);
});
});
describe('Error Handling - No Tags Scenario', () => {
it('should handle invalid inbox item operations gracefully when no tags exist', async () => {
// Try to get non-existent item
const getResponse = await agent.get('/api/inbox/999999');
expect(getResponse.status).toBe(404);
expect(getResponse.body.error).toBe('Inbox item not found.');
// Try to update non-existent item
const updateResponse = await agent
.patch('/api/inbox/999999')
.send({ content: 'Updated' });
expect(updateResponse.status).toBe(404);
expect(updateResponse.body.error).toBe('Inbox item not found.');
// Try to process non-existent item
const processResponse = await agent.patch(
'/api/inbox/999999/process'
);
expect(processResponse.status).toBe(404);
expect(processResponse.body.error).toBe('Inbox item not found.');
// Try to delete non-existent item
const deleteResponse = await agent.delete('/api/inbox/999999');
expect(deleteResponse.status).toBe(404);
expect(deleteResponse.body.error).toBe('Inbox item not found.');
});
it('should validate required fields when creating inbox items (no tags scenario)', async () => {
// Try to create item without content
const response = await agent.post('/api/inbox').send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Content is required');
});
});
});

View file

@ -4,272 +4,276 @@ const { InboxItem, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Inbox Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/inbox', () => {
it('should create a new inbox item', async () => {
const inboxData = {
content: 'Remember to buy groceries',
source: 'web'
};
const response = await agent
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(201);
expect(response.body.content).toBe(inboxData.content);
expect(response.body.source).toBe(inboxData.source);
expect(response.body.status).toBe('added');
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const inboxData = {
content: 'Test content'
};
const response = await request(app)
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require content', async () => {
const inboxData = {};
const response = await agent
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Content is required');
});
});
describe('GET /api/inbox', () => {
let inboxItem1, inboxItem2;
let user, agent;
beforeEach(async () => {
inboxItem1 = await InboxItem.create({
content: 'First item',
status: 'added',
user_id: user.id
});
user = await createTestUser({
email: 'test@example.com',
});
inboxItem2 = await InboxItem.create({
content: 'Second item',
status: 'processed',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user inbox items', async () => {
const response = await agent.get('/api/inbox');
describe('POST /api/inbox', () => {
it('should create a new inbox item', async () => {
const inboxData = {
content: 'Remember to buy groceries',
source: 'web',
};
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
expect(response.body.map(i => i.id)).toContain(inboxItem1.id);
const response = await agent.post('/api/inbox').send(inboxData);
expect(response.status).toBe(201);
expect(response.body.content).toBe(inboxData.content);
expect(response.body.source).toBe(inboxData.source);
expect(response.body.status).toBe('added');
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const inboxData = {
content: 'Test content',
};
const response = await request(app)
.post('/api/inbox')
.send(inboxData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require content', async () => {
const inboxData = {};
const response = await agent.post('/api/inbox').send(inboxData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Content is required');
});
});
it('should only return items with added status', async () => {
const response = await agent.get('/api/inbox');
describe('GET /api/inbox', () => {
let inboxItem1, inboxItem2;
expect(response.status).toBe(200);
expect(response.body.length).toBe(1);
expect(response.body[0].id).toBe(inboxItem1.id);
expect(response.body[0].status).toBe('added');
beforeEach(async () => {
inboxItem1 = await InboxItem.create({
content: 'First item',
status: 'added',
user_id: user.id,
});
inboxItem2 = await InboxItem.create({
content: 'Second item',
status: 'processed',
user_id: user.id,
});
});
it('should get all user inbox items', async () => {
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(1); // Only items with status 'added' are returned
expect(response.body.map((i) => i.id)).toContain(inboxItem1.id);
});
it('should only return items with added status', async () => {
const response = await agent.get('/api/inbox');
expect(response.status).toBe(200);
expect(response.body.length).toBe(1);
expect(response.body[0].id).toBe(inboxItem1.id);
expect(response.body[0].status).toBe('added');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/inbox');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/inbox');
describe('GET /api/inbox/:id', () => {
let inboxItem;
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id,
});
});
describe('GET /api/inbox/:id', () => {
let inboxItem;
it('should get inbox item by id', async () => {
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
expect(response.status).toBe(200);
expect(response.body.id).toBe(inboxItem.id);
expect(response.body.content).toBe(inboxItem.content);
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.get('/api/inbox/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it("should not allow access to other user's inbox items", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherInboxItem = await InboxItem.create({
content: 'Other content',
user_id: otherUser.id,
});
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).get(
`/api/inbox/${inboxItem.id}`
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should get inbox item by id', async () => {
const response = await agent.get(`/api/inbox/${inboxItem.id}`);
describe('PATCH /api/inbox/:id', () => {
let inboxItem;
expect(response.status).toBe(200);
expect(response.body.id).toBe(inboxItem.id);
expect(response.body.content).toBe(inboxItem.content);
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id,
});
});
it('should update inbox item', async () => {
const updateData = {
content: 'Updated content',
status: 'processed',
};
const response = await agent
.patch(`/api/inbox/${inboxItem.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.content).toBe(updateData.content);
expect(response.body.status).toBe(updateData.status);
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent
.patch('/api/inbox/999999')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/inbox/${inboxItem.id}`)
.send({ content: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.get('/api/inbox/999999');
describe('DELETE /api/inbox/:id', () => {
let inboxItem;
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id,
});
});
it('should delete inbox item', async () => {
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe(
'Inbox item successfully deleted'
);
// Verify inbox item status is updated to deleted
const deletedItem = await InboxItem.findByPk(inboxItem.id);
expect(deletedItem).not.toBeNull();
expect(deletedItem.status).toBe('deleted');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.delete('/api/inbox/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(
`/api/inbox/${inboxItem.id}`
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should not allow access to other user\'s inbox items', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
describe('PATCH /api/inbox/:id/process', () => {
let inboxItem;
const otherInboxItem = await InboxItem.create({
content: 'Other content',
user_id: otherUser.id
});
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id,
});
});
const response = await agent.get(`/api/inbox/${otherInboxItem.id}`);
it('should process inbox item', async () => {
const response = await agent.patch(
`/api/inbox/${inboxItem.id}/process`
);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.patch('/api/inbox/999999/process');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).patch(
`/api/inbox/${inboxItem.id}/process`
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/inbox/:id', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id
});
});
it('should update inbox item', async () => {
const updateData = {
content: 'Updated content',
status: 'processed'
};
const response = await agent
.patch(`/api/inbox/${inboxItem.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.content).toBe(updateData.content);
expect(response.body.status).toBe(updateData.status);
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent
.patch('/api/inbox/999999')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/inbox/${inboxItem.id}`)
.send({ content: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/inbox/:id', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
});
it('should delete inbox item', async () => {
const response = await agent.delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Inbox item successfully deleted');
// Verify inbox item status is updated to deleted
const deletedItem = await InboxItem.findByPk(inboxItem.id);
expect(deletedItem).not.toBeNull();
expect(deletedItem.status).toBe('deleted');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.delete('/api/inbox/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/inbox/${inboxItem.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/inbox/:id/process', () => {
let inboxItem;
beforeEach(async () => {
inboxItem = await InboxItem.create({
content: 'Test content',
status: 'added',
user_id: user.id
});
});
it('should process inbox item', async () => {
const response = await agent.patch(`/api/inbox/${inboxItem.id}/process`);
expect(response.status).toBe(200);
expect(response.body.status).toBe('processed');
});
it('should return 404 for non-existent inbox item', async () => {
const response = await agent.patch('/api/inbox/999999/process');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Inbox item not found.');
});
it('should require authentication', async () => {
const response = await request(app).patch(`/api/inbox/${inboxItem.id}/process`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});

View file

@ -4,305 +4,301 @@ const { Note, User, Project } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Notes Routes', () => {
let user, project, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/note', () => {
it('should create a new note', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
project_id: project.id
};
const response = await agent
.post('/api/note')
.send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBe(project.id);
expect(response.body.user_id).toBe(user.id);
});
it('should create note without project', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content'
};
const response = await agent
.post('/api/note')
.send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBeNull();
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content'
};
const response = await request(app)
.post('/api/note')
.send(noteData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('GET /api/notes', () => {
let note1, note2;
let user, project, agent;
beforeEach(async () => {
note1 = await Note.create({
title: 'Note 1',
content: 'First note content',
user_id: user.id,
project_id: project.id
});
user = await createTestUser({
email: 'test@example.com',
});
note2 = await Note.create({
title: 'Note 2',
content: 'Second note content',
user_id: user.id
});
project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user notes', async () => {
const response = await agent.get('/api/notes');
describe('POST /api/note', () => {
it('should create a new note', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
project_id: project.id,
};
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
expect(response.body.map(n => n.id)).toContain(note1.id);
expect(response.body.map(n => n.id)).toContain(note2.id);
const response = await agent.post('/api/note').send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBe(project.id);
expect(response.body.user_id).toBe(user.id);
});
it('should create note without project', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
};
const response = await agent.post('/api/note').send(noteData);
expect(response.status).toBe(201);
expect(response.body.title).toBe(noteData.title);
expect(response.body.content).toBe(noteData.content);
expect(response.body.project_id).toBeNull();
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content',
};
const response = await request(app)
.post('/api/note')
.send(noteData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should include project information', async () => {
const response = await agent.get('/api/notes');
describe('GET /api/notes', () => {
let note1, note2;
expect(response.status).toBe(200);
const noteWithProject = response.body.find(n => n.id === note1.id);
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.name).toBe(project.name);
beforeEach(async () => {
note1 = await Note.create({
title: 'Note 1',
content: 'First note content',
user_id: user.id,
project_id: project.id,
});
note2 = await Note.create({
title: 'Note 2',
content: 'Second note content',
user_id: user.id,
});
});
it('should get all user notes', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBe(2);
expect(response.body.map((n) => n.id)).toContain(note1.id);
expect(response.body.map((n) => n.id)).toContain(note2.id);
});
it('should include project information', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
const noteWithProject = response.body.find(
(n) => n.id === note1.id
);
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.name).toBe(project.name);
});
it('should return all notes when no filter is applied', async () => {
const response = await agent.get('/api/notes');
expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
expect(response.body.map((n) => n.id)).toContain(note1.id);
expect(response.body.map((n) => n.id)).toContain(note2.id);
});
it('should require authentication', async () => {
const response = await request(app).get('/api/notes');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return all notes when no filter is applied', async () => {
const response = await agent.get('/api/notes');
describe('GET /api/note/:id', () => {
let note;
expect(response.status).toBe(200);
expect(response.body.length).toBe(2);
expect(response.body.map(n => n.id)).toContain(note1.id);
expect(response.body.map(n => n.id)).toContain(note2.id);
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: project.id,
});
});
it('should get note by id', async () => {
const response = await agent.get(`/api/note/${note.id}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(note.id);
expect(response.body.title).toBe(note.title);
expect(response.body.content).toBe(note.content);
});
it('should return 404 for non-existent note', async () => {
const response = await agent.get('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it("should not allow access to other user's notes", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id,
});
const response = await agent.get(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/notes');
describe('PATCH /api/note/:id', () => {
let note;
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id,
});
});
describe('GET /api/note/:id', () => {
let note;
it('should update note', async () => {
const updateData = {
title: 'Updated Note',
content: 'Updated content',
project_id: project.id,
};
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: project.id
});
const response = await agent
.patch(`/api/note/${note.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.title).toBe(updateData.title);
expect(response.body.content).toBe(updateData.content);
expect(response.body.project_id).toBe(project.id);
});
it('should return 404 for non-existent note', async () => {
const response = await agent
.patch('/api/note/999999')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it("should not allow updating other user's notes", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id,
});
const response = await agent
.patch(`/api/note/${otherNote.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/note/${note.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should get note by id', async () => {
const response = await agent.get(`/api/note/${note.id}`);
describe('DELETE /api/note/:id', () => {
let note;
expect(response.status).toBe(200);
expect(response.body.id).toBe(note.id);
expect(response.body.title).toBe(note.title);
expect(response.body.content).toBe(note.content);
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
user_id: user.id,
});
});
it('should delete note', async () => {
const response = await agent.delete(`/api/note/${note.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Note deleted successfully.');
// Verify note is deleted
const deletedNote = await Note.findByPk(note.id);
expect(deletedNote).toBeNull();
});
it('should return 404 for non-existent note', async () => {
const response = await agent.delete('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it("should not allow deleting other user's notes", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id,
});
const response = await agent.delete(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent note', async () => {
const response = await agent.get('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow access to other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent.get(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/note/:id', () => {
let note;
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
content: 'Test content',
user_id: user.id
});
});
it('should update note', async () => {
const updateData = {
title: 'Updated Note',
content: 'Updated content',
project_id: project.id
};
const response = await agent
.patch(`/api/note/${note.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.title).toBe(updateData.title);
expect(response.body.content).toBe(updateData.content);
expect(response.body.project_id).toBe(project.id);
});
it('should return 404 for non-existent note', async () => {
const response = await agent
.patch('/api/note/999999')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow updating other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/note/${otherNote.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/note/${note.id}`)
.send({ title: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/note/:id', () => {
let note;
beforeEach(async () => {
note = await Note.create({
title: 'Test Note',
user_id: user.id
});
});
it('should delete note', async () => {
const response = await agent.delete(`/api/note/${note.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Note deleted successfully.');
// Verify note is deleted
const deletedNote = await Note.findByPk(note.id);
expect(deletedNote).toBeNull();
});
it('should return 404 for non-existent note', async () => {
const response = await agent.delete('/api/note/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should not allow deleting other user\'s notes', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherNote = await Note.create({
title: 'Other Note',
user_id: otherUser.id
});
const response = await agent.delete(`/api/note/${otherNote.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Note not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/note/${note.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});

View file

@ -4,300 +4,308 @@ const { Project, User, Area } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Projects Routes', () => {
let user, area, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
area = await Area.create({
name: 'Work',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/project', () => {
it('should create a new project', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
area_id: area.id
};
const response = await agent
.post('/api/project')
.send(projectData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(projectData.name);
expect(response.body.description).toBe(projectData.description);
expect(response.body.active).toBe(projectData.active);
expect(response.body.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
expect(response.body.priority).toBe(projectData.priority);
expect(response.body.area_id).toBe(area.id);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const projectData = {
name: 'Test Project'
};
const response = await request(app)
.post('/api/project')
.send(projectData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require project name', async () => {
const projectData = {
description: 'Project without name'
};
const response = await agent
.post('/api/project')
.send(projectData);
expect(response.status).toBe(400);
});
});
describe('GET /api/projects', () => {
let project1, project2;
let user, area, agent;
beforeEach(async () => {
project1 = await Project.create({
name: 'Project 1',
description: 'First project',
user_id: user.id,
area_id: area.id
});
user = await createTestUser({
email: 'test@example.com',
});
project2 = await Project.create({
name: 'Project 2',
description: 'Second project',
user_id: user.id
});
area = await Area.create({
name: 'Work',
user_id: user.id,
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user projects', async () => {
const response = await agent.get('/api/projects');
describe('POST /api/project', () => {
it('should create a new project', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
area_id: area.id,
};
expect(response.status).toBe(200);
expect(response.body.projects).toBeDefined();
expect(response.body.projects.length).toBe(2);
expect(response.body.projects.map(p => p.id)).toContain(project1.id);
expect(response.body.projects.map(p => p.id)).toContain(project2.id);
const response = await agent.post('/api/project').send(projectData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(projectData.name);
expect(response.body.description).toBe(projectData.description);
expect(response.body.active).toBe(projectData.active);
expect(response.body.pin_to_sidebar).toBe(
projectData.pin_to_sidebar
);
expect(response.body.priority).toBe(projectData.priority);
expect(response.body.area_id).toBe(area.id);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const projectData = {
name: 'Test Project',
};
const response = await request(app)
.post('/api/project')
.send(projectData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require project name', async () => {
const projectData = {
description: 'Project without name',
};
const response = await agent.post('/api/project').send(projectData);
expect(response.status).toBe(400);
});
});
it('should include area information', async () => {
const response = await agent.get('/api/projects');
describe('GET /api/projects', () => {
let project1, project2;
expect(response.status).toBe(200);
const projectWithArea = response.body.projects.find(p => p.id === project1.id);
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.name).toBe(area.name);
beforeEach(async () => {
project1 = await Project.create({
name: 'Project 1',
description: 'First project',
user_id: user.id,
area_id: area.id,
});
project2 = await Project.create({
name: 'Project 2',
description: 'Second project',
user_id: user.id,
});
});
it('should get all user projects', async () => {
const response = await agent.get('/api/projects');
expect(response.status).toBe(200);
expect(response.body.projects).toBeDefined();
expect(response.body.projects.length).toBe(2);
expect(response.body.projects.map((p) => p.id)).toContain(
project1.id
);
expect(response.body.projects.map((p) => p.id)).toContain(
project2.id
);
});
it('should include area information', async () => {
const response = await agent.get('/api/projects');
expect(response.status).toBe(200);
const projectWithArea = response.body.projects.find(
(p) => p.id === project1.id
);
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.name).toBe(area.name);
});
it('should require authentication', async () => {
const response = await request(app).get('/api/projects');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/projects');
describe('GET /api/project/:id', () => {
let project;
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
user_id: user.id,
area_id: area.id,
});
});
describe('GET /api/project/:id', () => {
let project;
it('should get project by id', async () => {
const response = await agent.get(`/api/project/${project.id}`);
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
user_id: user.id,
area_id: area.id
});
expect(response.status).toBe(200);
expect(response.body.id).toBe(project.id);
expect(response.body.name).toBe(project.name);
expect(response.body.description).toBe(project.description);
});
it('should return 404 for non-existent project', async () => {
const response = await agent.get('/api/project/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
});
it("should not allow access to other user's projects", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id,
});
const response = await agent.get(`/api/project/${otherProject.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
});
it('should require authentication', async () => {
const response = await request(app).get(
`/api/project/${project.id}`
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should get project by id', async () => {
const response = await agent.get(`/api/project/${project.id}`);
describe('PATCH /api/project/:id', () => {
let project;
expect(response.status).toBe(200);
expect(response.body.id).toBe(project.id);
expect(response.body.name).toBe(project.name);
expect(response.body.description).toBe(project.description);
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
active: false,
priority: 0,
user_id: user.id,
});
});
it('should update project', async () => {
const updateData = {
name: 'Updated Project',
description: 'Updated Description',
active: true,
priority: 2,
};
const response = await agent
.patch(`/api/project/${project.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
expect(response.body.active).toBe(updateData.active);
expect(response.body.priority).toBe(updateData.priority);
});
it('should return 404 for non-existent project', async () => {
const response = await agent
.patch('/api/project/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it("should not allow updating other user's projects", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id,
});
const response = await agent
.patch(`/api/project/${otherProject.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/project/${project.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent project', async () => {
const response = await agent.get('/api/project/999999');
describe('DELETE /api/project/:id', () => {
let project;
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
});
it('should delete project', async () => {
const response = await agent.delete(`/api/project/${project.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Project successfully deleted');
// Verify project is deleted
const deletedProject = await Project.findByPk(project.id);
expect(deletedProject).toBeNull();
});
it('should return 404 for non-existent project', async () => {
const response = await agent.delete('/api/project/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it("should not allow deleting other user's projects", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id,
});
const response = await agent.delete(
`/api/project/${otherProject.id}`
);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(
`/api/project/${project.id}`
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should not allow access to other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent.get(`/api/project/${otherProject.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/project/${project.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/project/:id', () => {
let project;
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
description: 'Test Description',
active: false,
priority: 0,
user_id: user.id
});
});
it('should update project', async () => {
const updateData = {
name: 'Updated Project',
description: 'Updated Description',
active: true,
priority: 2
};
const response = await agent
.patch(`/api/project/${project.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
expect(response.body.description).toBe(updateData.description);
expect(response.body.active).toBe(updateData.active);
expect(response.body.priority).toBe(updateData.priority);
});
it('should return 404 for non-existent project', async () => {
const response = await agent
.patch('/api/project/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should not allow updating other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/project/${otherProject.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/project/${project.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/project/:id', () => {
let project;
beforeEach(async () => {
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
});
it('should delete project', async () => {
const response = await agent.delete(`/api/project/${project.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Project successfully deleted');
// Verify project is deleted
const deletedProject = await Project.findByPk(project.id);
expect(deletedProject).toBeNull();
});
it('should return 404 for non-existent project', async () => {
const response = await agent.delete('/api/project/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should not allow deleting other user\'s projects', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherProject = await Project.create({
name: 'Other Project',
user_id: otherUser.id
});
const response = await agent.delete(`/api/project/${otherProject.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Project not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/project/${project.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});

View file

@ -3,166 +3,168 @@ const app = require('../../app');
const { createTestUser } = require('../helpers/testUtils');
describe('Quotes Routes', () => {
let user, agent;
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/quotes/random', () => {
it('should return a random quote', async () => {
const response = await agent.get('/api/quotes/random');
describe('GET /api/quotes/random', () => {
it('should return a random quote', async () => {
const response = await agent
.get('/api/quotes/random');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
expect(response.body.quote.length).toBeGreaterThan(0);
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
expect(response.body.quote.length).toBeGreaterThan(0);
it('should return different quotes on multiple requests', async () => {
const responses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
]);
// All responses should be successful
responses.forEach((response) => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
});
// With multiple requests, we should get at least some variety
// (though it's possible to get the same quote multiple times due to randomness)
const quotes = responses.map((r) => r.body.quote);
const uniqueQuotes = new Set(quotes);
// We expect at least 1 unique quote, but likely more
expect(uniqueQuotes.size).toBeGreaterThanOrEqual(1);
});
it('should return valid quote structure', async () => {
const response = await agent.get('/api/quotes/random');
expect(response.status).toBe(200);
expect(Object.keys(response.body)).toEqual(['quote']);
expect(response.body.quote).toBeTruthy();
expect(response.body.quote.trim()).toBe(response.body.quote);
});
});
it('should return different quotes on multiple requests', async () => {
const responses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random')
]);
describe('GET /api/quotes', () => {
it('should return all quotes with count', async () => {
const response = await agent.get('/api/quotes');
// All responses should be successful
responses.forEach(response => {
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quote');
expect(typeof response.body.quote).toBe('string');
});
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quotes');
expect(response.body).toHaveProperty('count');
expect(Array.isArray(response.body.quotes)).toBe(true);
expect(typeof response.body.count).toBe('number');
expect(response.body.quotes.length).toBe(response.body.count);
expect(response.body.count).toBeGreaterThan(0);
});
// With multiple requests, we should get at least some variety
// (though it's possible to get the same quote multiple times due to randomness)
const quotes = responses.map(r => r.body.quote);
const uniqueQuotes = new Set(quotes);
// We expect at least 1 unique quote, but likely more
expect(uniqueQuotes.size).toBeGreaterThanOrEqual(1);
it('should return valid quote array', async () => {
const response = await agent.get('/api/quotes');
expect(response.status).toBe(200);
// All quotes should be non-empty strings
response.body.quotes.forEach((quote) => {
expect(typeof quote).toBe('string');
expect(quote.length).toBeGreaterThan(0);
expect(quote.trim()).toBe(quote);
});
});
it('should return consistent data across requests', async () => {
const response1 = await agent.get('/api/quotes');
const response2 = await agent.get('/api/quotes');
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
// The quotes array should be the same across requests
expect(response1.body.quotes.length).toBe(
response2.body.quotes.length
);
expect(response1.body.count).toBe(response2.body.count);
// Verify the actual content is the same
expect(response1.body.quotes).toEqual(response2.body.quotes);
});
it('should return expected quote count', async () => {
const response = await agent.get('/api/quotes');
expect(response.status).toBe(200);
// Based on the configuration, we expect 20 quotes, but allow for fallback quotes
expect(response.body.count).toBeGreaterThanOrEqual(5);
expect(response.body.quotes.length).toBe(response.body.count);
});
it('should contain productivity-focused quotes', async () => {
const response = await agent.get('/api/quotes');
expect(response.status).toBe(200);
// Look for some productivity-related keywords in the quotes
const allQuotesText = response.body.quotes.join(' ').toLowerCase();
// These are common themes in productivity quotes
const productivityKeywords = [
'progress',
'task',
'goal',
'focus',
'accomplish',
'success',
'work',
'effort',
'achieve',
'time',
];
// At least some quotes should contain productivity-related terms
const hasProductivityContent = productivityKeywords.some(
(keyword) => allQuotesText.includes(keyword)
);
expect(hasProductivityContent).toBe(true);
});
});
it('should return valid quote structure', async () => {
const response = await agent
.get('/api/quotes/random');
describe('Quote randomness and consistency', () => {
it('should have random quotes that are part of the full quote set', async () => {
// Get all quotes first
const allQuotesResponse = await agent.get('/api/quotes');
const allQuotes = allQuotesResponse.body.quotes;
expect(response.status).toBe(200);
expect(Object.keys(response.body)).toEqual(['quote']);
expect(response.body.quote).toBeTruthy();
expect(response.body.quote.trim()).toBe(response.body.quote);
// Get several random quotes
const randomQuoteResponses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
]);
// Each random quote should be from the full set
randomQuoteResponses.forEach((response) => {
expect(response.status).toBe(200);
expect(allQuotes).toContain(response.body.quote);
});
});
});
});
describe('GET /api/quotes', () => {
it('should return all quotes with count', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('quotes');
expect(response.body).toHaveProperty('count');
expect(Array.isArray(response.body.quotes)).toBe(true);
expect(typeof response.body.count).toBe('number');
expect(response.body.quotes.length).toBe(response.body.count);
expect(response.body.count).toBeGreaterThan(0);
});
it('should return valid quote array', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// All quotes should be non-empty strings
response.body.quotes.forEach(quote => {
expect(typeof quote).toBe('string');
expect(quote.length).toBeGreaterThan(0);
expect(quote.trim()).toBe(quote);
});
});
it('should return consistent data across requests', async () => {
const response1 = await agent.get('/api/quotes');
const response2 = await agent.get('/api/quotes');
expect(response1.status).toBe(200);
expect(response2.status).toBe(200);
// The quotes array should be the same across requests
expect(response1.body.quotes.length).toBe(response2.body.quotes.length);
expect(response1.body.count).toBe(response2.body.count);
// Verify the actual content is the same
expect(response1.body.quotes).toEqual(response2.body.quotes);
});
it('should return expected quote count', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// Based on the configuration, we expect 20 quotes, but allow for fallback quotes
expect(response.body.count).toBeGreaterThanOrEqual(5);
expect(response.body.quotes.length).toBe(response.body.count);
});
it('should contain productivity-focused quotes', async () => {
const response = await agent
.get('/api/quotes');
expect(response.status).toBe(200);
// Look for some productivity-related keywords in the quotes
const allQuotesText = response.body.quotes.join(' ').toLowerCase();
// These are common themes in productivity quotes
const productivityKeywords = [
'progress', 'task', 'goal', 'focus', 'accomplish',
'success', 'work', 'effort', 'achieve', 'time'
];
// At least some quotes should contain productivity-related terms
const hasProductivityContent = productivityKeywords.some(keyword =>
allQuotesText.includes(keyword)
);
expect(hasProductivityContent).toBe(true);
});
});
describe('Quote randomness and consistency', () => {
it('should have random quotes that are part of the full quote set', async () => {
// Get all quotes first
const allQuotesResponse = await agent.get('/api/quotes');
const allQuotes = allQuotesResponse.body.quotes;
// Get several random quotes
const randomQuoteResponses = await Promise.all([
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random'),
agent.get('/api/quotes/random')
]);
// Each random quote should be from the full set
randomQuoteResponses.forEach(response => {
expect(response.status).toBe(200);
expect(allQuotes).toContain(response.body.quote);
});
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -4,267 +4,259 @@ const { Tag, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Tags Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/tag', () => {
it('should create a new tag', async () => {
const tagData = {
name: 'work'
};
const response = await agent
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(tagData.name);
expect(response.body.id).toBeDefined();
});
it('should require authentication', async () => {
const tagData = {
name: 'work'
};
const response = await request(app)
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require tag name', async () => {
const tagData = {};
const response = await agent
.post('/api/tag')
.send(tagData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Tag name is required');
});
});
describe('GET /api/tags', () => {
let tag1, tag2;
let user, agent;
beforeEach(async () => {
tag1 = await Tag.create({
name: 'work',
user_id: user.id
});
user = await createTestUser({
email: 'test@example.com',
});
tag2 = await Tag.create({
name: 'personal',
user_id: user.id
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user tags', async () => {
const response = await agent.get('/api/tags');
describe('POST /api/tag', () => {
it('should create a new tag', async () => {
const tagData = {
name: 'work',
};
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map(t => t.id)).toContain(tag1.id);
expect(response.body.map(t => t.id)).toContain(tag2.id);
const response = await agent.post('/api/tag').send(tagData);
expect(response.status).toBe(201);
expect(response.body.name).toBe(tagData.name);
expect(response.body.id).toBeDefined();
});
it('should require authentication', async () => {
const tagData = {
name: 'work',
};
const response = await request(app).post('/api/tag').send(tagData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require tag name', async () => {
const tagData = {};
const response = await agent.post('/api/tag').send(tagData);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Tag name is required');
});
});
it('should order tags by name', async () => {
const response = await agent.get('/api/tags');
describe('GET /api/tags', () => {
let tag1, tag2;
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('personal'); // P comes before W
expect(response.body[1].name).toBe('work');
beforeEach(async () => {
tag1 = await Tag.create({
name: 'work',
user_id: user.id,
});
tag2 = await Tag.create({
name: 'personal',
user_id: user.id,
});
});
it('should get all user tags', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map((t) => t.id)).toContain(tag1.id);
expect(response.body.map((t) => t.id)).toContain(tag2.id);
});
it('should order tags by name', async () => {
const response = await agent.get('/api/tags');
expect(response.status).toBe(200);
expect(response.body[0].name).toBe('personal'); // P comes before W
expect(response.body[1].name).toBe('work');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tags');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tags');
describe('GET /api/tag/:id', () => {
let tag;
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id,
});
});
describe('GET /api/tag/:id', () => {
let tag;
it('should get tag by id', async () => {
const response = await agent.get(`/api/tag/${tag.id}`);
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
expect(response.status).toBe(200);
expect(response.body.id).toBe(tag.id);
expect(response.body.name).toBe(tag.name);
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.get('/api/tag/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it("should not allow access to other user's tags", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id,
});
const response = await agent.get(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should get tag by id', async () => {
const response = await agent.get(`/api/tag/${tag.id}`);
describe('PATCH /api/tag/:id', () => {
let tag;
expect(response.status).toBe(200);
expect(response.body.id).toBe(tag.id);
expect(response.body.name).toBe(tag.name);
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id,
});
});
it('should update tag', async () => {
const updateData = {
name: 'updated-work',
};
const response = await agent
.patch(`/api/tag/${tag.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
});
it('should return 404 for non-existent tag', async () => {
const response = await agent
.patch('/api/tag/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it("should not allow updating other user's tags", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id,
});
const response = await agent
.patch(`/api/tag/${otherTag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/tag/${tag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.get('/api/tag/999999');
describe('DELETE /api/tag/:id', () => {
let tag;
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id,
});
});
it('should delete tag', async () => {
const response = await agent.delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Tag successfully deleted');
// Verify tag is deleted
const deletedTag = await Tag.findByPk(tag.id);
expect(deletedTag).toBeNull();
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.delete('/api/tag/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it("should not allow deleting other user's tags", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id,
});
const response = await agent.delete(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should not allow access to other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent.get(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).get(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/tag/:id', () => {
let tag;
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
});
it('should update tag', async () => {
const updateData = {
name: 'updated-work'
};
const response = await agent
.patch(`/api/tag/${tag.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.name).toBe(updateData.name);
});
it('should return 404 for non-existent tag', async () => {
const response = await agent
.patch('/api/tag/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should not allow updating other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/tag/${otherTag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/tag/${tag.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/tag/:id', () => {
let tag;
beforeEach(async () => {
tag = await Tag.create({
name: 'work',
user_id: user.id
});
});
it('should delete tag', async () => {
const response = await agent.delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Tag successfully deleted');
// Verify tag is deleted
const deletedTag = await Tag.findByPk(tag.id);
expect(deletedTag).toBeNull();
});
it('should return 404 for non-existent tag', async () => {
const response = await agent.delete('/api/tag/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should not allow deleting other user\'s tags', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTag = await Tag.create({
name: 'other-tag',
user_id: otherUser.id
});
const response = await agent.delete(`/api/tag/${otherTag.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Tag not found');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/tag/${tag.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});

View file

@ -4,271 +4,260 @@ const { Task, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Tasks Routes', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/task', () => {
it('should create a new task', async () => {
const taskData = {
name: 'Test Task',
note: 'Test Note',
priority: 1,
status: 0
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(taskData.name);
expect(response.body.note).toBe(taskData.note);
expect(response.body.priority).toBe(taskData.priority);
expect(response.body.status).toBe(taskData.status);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const taskData = {
name: 'Test Task'
};
const response = await request(app)
.post('/api/task')
.send(taskData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require task name', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
const taskData = {
description: 'Test Description'
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(400);
// Restore original console.error
console.error = originalConsoleError;
});
});
describe('GET /api/tasks', () => {
let task1, task2;
let user, agent;
beforeEach(async () => {
task1 = await Task.create({
name: 'Task 1',
description: 'Description 1',
user_id: user.id,
today: true
});
user = await createTestUser({
email: 'test@example.com',
});
task2 = await Task.create({
name: 'Task 2',
description: 'Description 2',
user_id: user.id,
today: false
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should get all user tasks', async () => {
const response = await agent.get('/api/tasks');
describe('POST /api/task', () => {
it('should create a new task', async () => {
const taskData = {
name: 'Test Task',
note: 'Test Note',
priority: 1,
status: 0,
};
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
expect(response.body.tasks.map(t => t.id)).toContain(task1.id);
expect(response.body.tasks.map(t => t.id)).toContain(task2.id);
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(taskData.name);
expect(response.body.note).toBe(taskData.note);
expect(response.body.priority).toBe(taskData.priority);
expect(response.body.status).toBe(taskData.status);
expect(response.body.user_id).toBe(user.id);
});
it('should require authentication', async () => {
const taskData = {
name: 'Test Task',
};
const response = await request(app)
.post('/api/task')
.send(taskData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require task name', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
const taskData = {
description: 'Test Description',
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(400);
// Restore original console.error
console.error = originalConsoleError;
});
});
it('should filter today tasks (returns all user tasks)', async () => {
const response = await agent.get('/api/tasks?type=today');
describe('GET /api/tasks', () => {
let task1, task2;
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
// Both tasks should be returned as "today" doesn't filter by the today field
beforeEach(async () => {
task1 = await Task.create({
name: 'Task 1',
description: 'Description 1',
user_id: user.id,
today: true,
});
task2 = await Task.create({
name: 'Task 2',
description: 'Description 2',
user_id: user.id,
today: false,
});
});
it('should get all user tasks', async () => {
const response = await agent.get('/api/tasks');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
expect(response.body.tasks.map((t) => t.id)).toContain(task1.id);
expect(response.body.tasks.map((t) => t.id)).toContain(task2.id);
});
it('should filter today tasks (returns all user tasks)', async () => {
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
expect(response.body.tasks.length).toBe(2);
// Both tasks should be returned as "today" doesn't filter by the today field
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tasks');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should require authentication', async () => {
const response = await request(app).get('/api/tasks');
// Note: No individual task GET route exists in the current API
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('PATCH /api/task/:id', () => {
let task;
// Note: No individual task GET route exists in the current API
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
description: 'Test Description',
priority: 0,
status: 0,
user_id: user.id,
});
});
describe('PATCH /api/task/:id', () => {
let task;
it('should update task', async () => {
const updateData = {
name: 'Updated Task',
note: 'Updated Note',
priority: 2,
status: 1,
};
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
description: 'Test Description',
priority: 0,
status: 0,
user_id: user.id
});
const response = await agent
.patch(`/api/task/${task.id}`)
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(updateData.name);
expect(response.body.note).toBe(updateData.note);
expect(response.body.priority).toBe(updateData.priority);
expect(response.body.status).toBe(updateData.status);
});
it('should return 404 for non-existent task', async () => {
const response = await agent
.patch('/api/task/999999')
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it("should not allow updating other user's tasks", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id,
});
const response = await agent
.patch(`/api/task/${otherTask.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/task/${task.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should update task', async () => {
const updateData = {
name: 'Updated Task',
note: 'Updated Note',
priority: 2,
status: 1
};
describe('DELETE /api/task/:id', () => {
let task;
const response = await agent
.patch(`/api/task/${task.id}`)
.send(updateData);
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id,
});
});
expect(response.status).toBe(200);
expect(response.body.id).toBeDefined();
expect(response.body.name).toBe(updateData.name);
expect(response.body.note).toBe(updateData.note);
expect(response.body.priority).toBe(updateData.priority);
expect(response.body.status).toBe(updateData.status);
it('should delete task', async () => {
const response = await agent.delete(`/api/task/${task.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Task successfully deleted');
// Verify task is deleted
const deletedTask = await Task.findByPk(task.id);
expect(deletedTask).toBeNull();
});
it('should return 404 for non-existent task', async () => {
const response = await agent.delete('/api/task/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it("should not allow deleting other user's tasks", async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id,
});
const response = await agent.delete(`/api/task/${otherTask.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/task/${task.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
it('should return 404 for non-existent task', async () => {
const response = await agent
.patch('/api/task/999999')
.send({ name: 'Updated' });
describe('Task with tags', () => {
it('should create task with tags', async () => {
const taskData = {
name: 'Test Task',
tags: [{ name: 'work' }, { name: 'urgent' }],
};
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.Tags).toBeDefined();
expect(response.body.Tags.length).toBe(2);
expect(response.body.Tags.map((t) => t.name)).toContain('work');
expect(response.body.Tags.map((t) => t.name)).toContain('urgent');
});
});
it('should not allow updating other user\'s tasks', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id
});
const response = await agent
.patch(`/api/task/${otherTask.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app)
.patch(`/api/task/${task.id}`)
.send({ name: 'Updated' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('DELETE /api/task/:id', () => {
let task;
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id
});
});
it('should delete task', async () => {
const response = await agent.delete(`/api/task/${task.id}`);
expect(response.status).toBe(200);
expect(response.body.message).toBe('Task successfully deleted');
// Verify task is deleted
const deletedTask = await Task.findByPk(task.id);
expect(deletedTask).toBeNull();
});
it('should return 404 for non-existent task', async () => {
const response = await agent.delete('/api/task/999999');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should not allow deleting other user\'s tasks', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const otherTask = await Task.create({
name: 'Other Task',
user_id: otherUser.id
});
const response = await agent.delete(`/api/task/${otherTask.id}`);
expect(response.status).toBe(404);
expect(response.body.error).toBe('Task not found.');
});
it('should require authentication', async () => {
const response = await request(app).delete(`/api/task/${task.id}`);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
describe('Task with tags', () => {
it('should create task with tags', async () => {
const taskData = {
name: 'Test Task',
tags: [
{ name: 'work' },
{ name: 'urgent' }
]
};
const response = await agent
.post('/api/task')
.send(taskData);
expect(response.status).toBe(201);
expect(response.body.Tags).toBeDefined();
expect(response.body.Tags.length).toBe(2);
expect(response.body.Tags.map(t => t.name)).toContain('work');
expect(response.body.Tags.map(t => t.name)).toContain('urgent');
});
});
});
});

View file

@ -3,363 +3,399 @@ const telegramPoller = require('../../services/telegramPoller');
// Mock the HTTPS module to simulate Telegram API responses
jest.mock('https', () => {
const mockResponse = {
on: jest.fn((event, callback) => {
if (event === 'data') {
// Simulate API response with duplicate updates
callback(JSON.stringify({
ok: true,
result: [
{
update_id: 1001,
message: {
message_id: 123,
text: 'Buy groceries from the store',
chat: { id: 987654321 },
date: Math.floor(Date.now() / 1000)
}
const mockResponse = {
on: jest.fn((event, callback) => {
if (event === 'data') {
// Simulate API response with duplicate updates
callback(
JSON.stringify({
ok: true,
result: [
{
update_id: 1001,
message: {
message_id: 123,
text: 'Buy groceries from the store',
chat: { id: 987654321 },
date: Math.floor(Date.now() / 1000),
},
},
],
})
);
} else if (event === 'end') {
callback();
}
]
}));
} else if (event === 'end') {
callback();
}
})
};
}),
};
const mockRequest = {
on: jest.fn(),
write: jest.fn(),
end: jest.fn()
};
const mockRequest = {
on: jest.fn(),
write: jest.fn(),
end: jest.fn(),
};
return {
get: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
}),
request: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
})
};
return {
get: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
}),
request: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
}),
};
});
describe('Telegram Duplicate Message Scenario', () => {
let testUser;
let consoleMessages;
let testUser;
let consoleMessages;
beforeAll(async () => {
await sequelize.sync({ force: true });
// Capture console logs
consoleMessages = [];
const originalConsoleLog = console.log;
console.log = (...args) => {
consoleMessages.push(args.join(' '));
originalConsoleLog(...args);
};
});
beforeAll(async () => {
await sequelize.sync({ force: true });
beforeEach(async () => {
consoleMessages = [];
// Create test user with Telegram configuration
testUser = await User.create({
email: 'telegram-user@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'real-bot-token-456',
telegram_chat_id: '987654321'
// Capture console logs
consoleMessages = [];
const originalConsoleLog = console.log;
console.log = (...args) => {
consoleMessages.push(args.join(' '));
originalConsoleLog(...args);
};
});
// Clear inbox
await InboxItem.destroy({ where: {} });
// Reset poller
telegramPoller.stopPolling();
});
beforeEach(async () => {
consoleMessages = [];
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
// Create test user with Telegram configuration
testUser = await User.create({
email: 'telegram-user@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'real-bot-token-456',
telegram_chat_id: '987654321',
});
afterAll(async () => {
await sequelize.close();
});
// Clear inbox
await InboxItem.destroy({ where: {} });
describe('Real-world Duplicate Scenarios', () => {
test('should prevent duplicates when same message is processed twice due to network issues', async () => {
const messageContent = 'Buy groceries from the store';
const messageId = 123;
const updateId = 1001;
// Simulate first message processing
const inboxItem1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: messageId }
});
// Wait a moment (simulating network delay)
await new Promise(resolve => setTimeout(resolve, 50));
// Simulate duplicate processing attempt (same message, different processing cycle)
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
}
});
// Should find the existing item
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(inboxItem1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
// Reset poller
telegramPoller.stopPolling();
});
test('should handle rapid consecutive messages without creating duplicates', async () => {
const messages = [
{ content: 'First message', messageId: 201, updateId: 2001 },
{ content: 'Second message', messageId: 202, updateId: 2002 },
{ content: 'First message', messageId: 203, updateId: 2003 }, // Duplicate content
{ content: 'Third message', messageId: 204, updateId: 2004 }
];
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
// Process all messages rapidly
const createdItems = [];
for (const msg of messages) {
try {
// Check for existing item first (simulating the duplicate prevention logic)
const existingItem = await InboxItem.findOne({
where: {
content: msg.content,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
}
}
});
afterAll(async () => {
await sequelize.close();
});
if (existingItem) {
console.log(`Duplicate detected: "${msg.content}"`);
createdItems.push(existingItem);
} else {
const newItem = await InboxItem.create({
content: msg.content,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: msg.messageId }
describe('Real-world Duplicate Scenarios', () => {
test('should prevent duplicates when same message is processed twice due to network issues', async () => {
const messageContent = 'Buy groceries from the store';
const messageId = 123;
const updateId = 1001;
// Simulate first message processing
const inboxItem1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: messageId },
});
createdItems.push(newItem);
}
} catch (error) {
console.error('Error processing message:', error);
}
}
// Should have 3 unique items (first, second, third) - duplicate "First message" should be prevented
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(3);
const contentCounts = allItems.reduce((acc, item) => {
acc[item.content] = (acc[item.content] || 0) + 1;
return acc;
}, {});
expect(contentCounts['First message']).toBe(1);
expect(contentCounts['Second message']).toBe(1);
expect(contentCounts['Third message']).toBe(1);
});
// Wait a moment (simulating network delay)
await new Promise((resolve) => setTimeout(resolve, 50));
test('should track update IDs correctly to prevent reprocessing', async () => {
// Simulate the internal update tracking logic
const processedUpdates = new Set();
const updates = [
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } },
{ update_id: 3002, message: { text: 'Message 2', message_id: 302, chat: { id: 987654321 } } },
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } }, // Duplicate update
{ update_id: 3003, message: { text: 'Message 3', message_id: 303, chat: { id: 987654321 } } }
];
// Simulate duplicate processing attempt (same message, different processing cycle)
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff,
},
},
});
const processedCount = { count: 0 };
for (const update of updates) {
const updateKey = `${testUser.id}-${update.update_id}`;
if (!processedUpdates.has(updateKey)) {
// Simulate processing the update
processedUpdates.add(updateKey);
processedCount.count++;
// Simulate creating inbox item
await InboxItem.create({
content: update.message.text,
source: 'telegram',
user_id: testUser.id,
metadata: {
telegram_message_id: update.message.message_id,
update_id: update.update_id
// Should find the existing item
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(inboxItem1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(1);
});
test('should handle rapid consecutive messages without creating duplicates', async () => {
const messages = [
{ content: 'First message', messageId: 201, updateId: 2001 },
{ content: 'Second message', messageId: 202, updateId: 2002 },
{ content: 'First message', messageId: 203, updateId: 2003 }, // Duplicate content
{ content: 'Third message', messageId: 204, updateId: 2004 },
];
// Process all messages rapidly
const createdItems = [];
for (const msg of messages) {
try {
// Check for existing item first (simulating the duplicate prevention logic)
const existingItem = await InboxItem.findOne({
where: {
content: msg.content,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(
Date.now() - 30000
),
},
},
});
if (existingItem) {
console.log(`Duplicate detected: "${msg.content}"`);
createdItems.push(existingItem);
} else {
const newItem = await InboxItem.create({
content: msg.content,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: msg.messageId },
});
createdItems.push(newItem);
}
} catch (error) {
console.error('Error processing message:', error);
}
}
});
} else {
console.log(`Skipping already processed update: ${update.update_id}`);
}
}
// Should have processed 3 unique updates (3001, 3002, 3003)
expect(processedCount.count).toBe(3);
expect(processedUpdates.size).toBe(3);
// Verify inbox items
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(3);
// Should have 3 unique items (first, second, third) - duplicate "First message" should be prevented
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(3);
const contentCounts = allItems.reduce((acc, item) => {
acc[item.content] = (acc[item.content] || 0) + 1;
return acc;
}, {});
expect(contentCounts['First message']).toBe(1);
expect(contentCounts['Second message']).toBe(1);
expect(contentCounts['Third message']).toBe(1);
});
test('should track update IDs correctly to prevent reprocessing', async () => {
// Simulate the internal update tracking logic
const processedUpdates = new Set();
const updates = [
{
update_id: 3001,
message: {
text: 'Message 1',
message_id: 301,
chat: { id: 987654321 },
},
},
{
update_id: 3002,
message: {
text: 'Message 2',
message_id: 302,
chat: { id: 987654321 },
},
},
{
update_id: 3001,
message: {
text: 'Message 1',
message_id: 301,
chat: { id: 987654321 },
},
}, // Duplicate update
{
update_id: 3003,
message: {
text: 'Message 3',
message_id: 303,
chat: { id: 987654321 },
},
},
];
const processedCount = { count: 0 };
for (const update of updates) {
const updateKey = `${testUser.id}-${update.update_id}`;
if (!processedUpdates.has(updateKey)) {
// Simulate processing the update
processedUpdates.add(updateKey);
processedCount.count++;
// Simulate creating inbox item
await InboxItem.create({
content: update.message.text,
source: 'telegram',
user_id: testUser.id,
metadata: {
telegram_message_id: update.message.message_id,
update_id: update.update_id,
},
});
} else {
console.log(
`Skipping already processed update: ${update.update_id}`
);
}
}
// Should have processed 3 unique updates (3001, 3002, 3003)
expect(processedCount.count).toBe(3);
expect(processedUpdates.size).toBe(3);
// Verify inbox items
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(3);
});
test('should handle poller restart without creating duplicates', async () => {
// Create initial inbox item
const initialItem = await InboxItem.create({
content: 'Message before restart',
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 401, update_id: 4001 },
});
// Add user to poller
await telegramPoller.addUser(testUser);
// Simulate getting status
let status = telegramPoller.getStatus();
expect(status.running).toBe(true);
expect(status.usersCount).toBe(1);
// Stop poller (simulating restart)
telegramPoller.stopPolling();
status = telegramPoller.getStatus();
expect(status.running).toBe(false);
// Start again
await telegramPoller.addUser(testUser);
status = telegramPoller.getStatus();
expect(status.running).toBe(true);
// The poller should maintain its state correctly
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(1);
expect(allItems[0].id).toBe(initialItem.id);
});
test('should cleanup old processed updates to prevent memory leaks', async () => {
// Test the memory management logic
const processedUpdates = new Set();
// Add many processed updates
for (let i = 1; i <= 1200; i++) {
processedUpdates.add(`${testUser.id}-${i}`);
}
expect(processedUpdates.size).toBe(1200);
// Simulate the cleanup logic (keeping only 1000 most recent)
if (processedUpdates.size > 1000) {
const allEntries = Array.from(processedUpdates);
const oldestEntries = allEntries.slice(0, 200); // Remove oldest 200
oldestEntries.forEach((entry) =>
processedUpdates.delete(entry)
);
}
expect(processedUpdates.size).toBe(1000);
// Verify oldest entries are removed
expect(processedUpdates.has(`${testUser.id}-1`)).toBe(false);
expect(processedUpdates.has(`${testUser.id}-200`)).toBe(false);
// Verify newest entries are kept
expect(processedUpdates.has(`${testUser.id}-1200`)).toBe(true);
expect(processedUpdates.has(`${testUser.id}-1000`)).toBe(true);
});
});
test('should handle poller restart without creating duplicates', async () => {
// Create initial inbox item
const initialItem = await InboxItem.create({
content: 'Message before restart',
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 401, update_id: 4001 }
});
describe('Edge Cases', () => {
test('should handle identical messages from different Telegram message IDs', async () => {
const messageContent = 'Identical content';
// Add user to poller
await telegramPoller.addUser(testUser);
// Simulate getting status
let status = telegramPoller.getStatus();
expect(status.running).toBe(true);
expect(status.usersCount).toBe(1);
// Create first message
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 501 },
});
// Stop poller (simulating restart)
telegramPoller.stopPolling();
status = telegramPoller.getStatus();
expect(status.running).toBe(false);
// Wait a moment
await new Promise((resolve) => setTimeout(resolve, 100));
// Start again
await telegramPoller.addUser(testUser);
status = telegramPoller.getStatus();
expect(status.running).toBe(true);
// Try to create with same content but different message ID
// This should be prevented by the content-based duplicate check
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff,
},
},
});
// The poller should maintain its state correctly
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
expect(allItems[0].id).toBe(initialItem.id);
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(item1.id);
});
test('should allow same content after time window expires', async () => {
const messageContent = 'Time-based test message';
// Create first item with old timestamp
const oldTimestamp = new Date(Date.now() - 35000); // 35 seconds ago
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: oldTimestamp,
updated_at: oldTimestamp,
metadata: { telegram_message_id: 601 },
});
// Now try to create new item with same content
const newItem = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 602 },
});
// Should be allowed since the old one is outside the 30-second window
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(2);
});
});
test('should cleanup old processed updates to prevent memory leaks', async () => {
// Test the memory management logic
const processedUpdates = new Set();
// Add many processed updates
for (let i = 1; i <= 1200; i++) {
processedUpdates.add(`${testUser.id}-${i}`);
}
expect(processedUpdates.size).toBe(1200);
// Simulate the cleanup logic (keeping only 1000 most recent)
if (processedUpdates.size > 1000) {
const allEntries = Array.from(processedUpdates);
const oldestEntries = allEntries.slice(0, 200); // Remove oldest 200
oldestEntries.forEach(entry => processedUpdates.delete(entry));
}
expect(processedUpdates.size).toBe(1000);
// Verify oldest entries are removed
expect(processedUpdates.has(`${testUser.id}-1`)).toBe(false);
expect(processedUpdates.has(`${testUser.id}-200`)).toBe(false);
// Verify newest entries are kept
expect(processedUpdates.has(`${testUser.id}-1200`)).toBe(true);
expect(processedUpdates.has(`${testUser.id}-1000`)).toBe(true);
});
});
describe('Edge Cases', () => {
test('should handle identical messages from different Telegram message IDs', async () => {
const messageContent = 'Identical content';
// Create first message
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 501 }
});
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 100));
// Try to create with same content but different message ID
// This should be prevented by the content-based duplicate check
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
}
});
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(item1.id);
});
test('should allow same content after time window expires', async () => {
const messageContent = 'Time-based test message';
// Create first item with old timestamp
const oldTimestamp = new Date(Date.now() - 35000); // 35 seconds ago
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: oldTimestamp,
updated_at: oldTimestamp,
metadata: { telegram_message_id: 601 }
});
// Now try to create new item with same content
const newItem = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 602 }
});
// Should be allowed since the old one is outside the 30-second window
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(2);
});
});
});
});

View file

@ -4,322 +4,339 @@ const { User, InboxItem, sequelize } = require('../../models');
const telegramPoller = require('../../services/telegramPoller');
describe('Telegram Duplicate Prevention Integration Tests', () => {
let testUser;
let originalConsoleLog;
let logMessages;
let testUser;
let originalConsoleLog;
let logMessages;
beforeAll(async () => {
// Capture console.log for verification
originalConsoleLog = console.log;
logMessages = [];
console.log = (...args) => {
logMessages.push(args.join(' '));
originalConsoleLog(...args);
};
beforeAll(async () => {
// Capture console.log for verification
originalConsoleLog = console.log;
logMessages = [];
console.log = (...args) => {
logMessages.push(args.join(' '));
originalConsoleLog(...args);
};
await sequelize.sync({ force: true });
});
beforeEach(async () => {
logMessages = [];
// Create test user
testUser = await User.create({
email: 'test-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-123',
telegram_chat_id: '987654321'
await sequelize.sync({ force: true });
});
// Clear any existing inbox items
await InboxItem.destroy({ where: {} });
// Stop and reset poller
telegramPoller.stopPolling();
});
beforeEach(async () => {
logMessages = [];
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
afterAll(async () => {
console.log = originalConsoleLog;
await sequelize.close();
});
describe('Database-level Duplicate Prevention', () => {
test('should prevent duplicate inbox items with same content within 30 seconds', async () => {
const messageContent = 'Test duplicate message';
// Create first inbox item
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 123 }
});
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 100));
// Try to create duplicate item (should be prevented)
const duplicateCheck = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
}
}
});
expect(duplicateCheck).toBeTruthy();
expect(duplicateCheck.id).toBe(item1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
});
test('should allow duplicate content after 30 seconds', async () => {
const messageContent = 'Test time-based duplicate';
// Create first item with backdated timestamp
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: new Date(Date.now() - 35000), // 35 seconds ago
metadata: { telegram_message_id: 124 }
});
// Create second item (should be allowed)
const item2 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 125 }
});
// Verify both items exist
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(2);
});
test('should allow same content for different users', async () => {
// Create second user
const testUser2 = await User.create({
email: 'test2-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-456',
telegram_chat_id: '123456789'
});
const messageContent = 'Shared message content';
// Create item for first user
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 126 }
});
// Create item for second user (should be allowed)
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser2.id,
metadata: { telegram_message_id: 127 }
});
// Verify both items exist
const allItems = await InboxItem.findAll();
expect(allItems).toHaveLength(2);
const user1Items = allItems.filter(item => item.user_id === testUser.id);
const user2Items = allItems.filter(item => item.user_id === testUser2.id);
expect(user1Items).toHaveLength(1);
expect(user2Items).toHaveLength(1);
});
});
describe('Poller State Management', () => {
test('should add and remove users correctly', async () => {
const initialStatus = telegramPoller.getStatus();
expect(initialStatus.usersCount).toBe(0);
expect(initialStatus.running).toBe(false);
// Add user
const addResult = await telegramPoller.addUser(testUser);
expect(addResult).toBe(true);
const statusAfterAdd = telegramPoller.getStatus();
expect(statusAfterAdd.usersCount).toBe(1);
expect(statusAfterAdd.running).toBe(true);
// Remove user
const removeResult = telegramPoller.removeUser(testUser.id);
expect(removeResult).toBe(true);
const statusAfterRemove = telegramPoller.getStatus();
expect(statusAfterRemove.usersCount).toBe(0);
expect(statusAfterRemove.running).toBe(false);
});
test('should not add user without telegram token', async () => {
const userWithoutToken = await User.create({
email: 'no-token@example.com',
password_digest: 'hashedpassword'
// No telegram_bot_token
});
const addResult = await telegramPoller.addUser(userWithoutToken);
expect(addResult).toBe(false);
const status = telegramPoller.getStatus();
expect(status.usersCount).toBe(0);
});
test('should handle adding same user multiple times', async () => {
// Add user first time
await telegramPoller.addUser(testUser);
const status1 = telegramPoller.getStatus();
expect(status1.usersCount).toBe(1);
// Add same user again
await telegramPoller.addUser(testUser);
const status2 = telegramPoller.getStatus();
expect(status2.usersCount).toBe(1); // Should still be 1
});
});
describe('Update Processing Logic', () => {
test('should handle updates with proper ID tracking', async () => {
await telegramPoller.addUser(testUser);
// Simulate updates (this tests the internal logic without actual HTTP calls)
const mockUpdates = [
{
update_id: 1001,
message: {
message_id: 501,
text: 'First message',
chat: { id: 987654321 }
}
},
{
update_id: 1002,
message: {
message_id: 502,
text: 'Second message',
chat: { id: 987654321 }
}
}
];
// Test highest update ID calculation
const highestId = telegramPoller._getHighestUpdateId(mockUpdates);
expect(highestId).toBe(1002);
// Test update key generation (simulating internal logic)
const updateKeys = mockUpdates.map(update => `${testUser.id}-${update.update_id}`);
expect(updateKeys).toEqual([`${testUser.id}-1001`, `${testUser.id}-1002`]);
});
test('should properly track processed updates', async () => {
// Test the Set-based tracking logic
const processedUpdates = new Set();
// Add some processed updates
processedUpdates.add('1-1001');
processedUpdates.add('1-1002');
// Test filtering logic
const newUpdates = [
{ update_id: 1001 }, // Should be filtered out
{ update_id: 1002 }, // Should be filtered out
{ update_id: 1003 } // Should remain
].filter(update => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(1003);
});
test('should handle memory management for processed updates', async () => {
// Simulate the cleanup logic
const processedUpdates = new Set();
// Add many updates (more than the 1000 limit)
for (let i = 1; i <= 1100; i++) {
processedUpdates.add(`1-${i}`);
}
expect(processedUpdates.size).toBe(1100);
// Simulate cleanup (remove oldest 100)
if (processedUpdates.size > 1000) {
const oldestEntries = Array.from(processedUpdates).slice(0, 100);
oldestEntries.forEach(entry => processedUpdates.delete(entry));
}
expect(processedUpdates.size).toBe(1000);
expect(processedUpdates.has('1-1')).toBe(false); // Oldest should be removed
expect(processedUpdates.has('1-1100')).toBe(true); // Newest should remain
});
});
describe('Error Handling', () => {
test('should handle database errors gracefully', async () => {
// Mock InboxItem.create to throw an error
const originalCreate = InboxItem.create;
InboxItem.create = jest.fn().mockRejectedValue(new Error('Database error'));
try {
await InboxItem.create({
content: 'Test error handling',
source: 'telegram',
user_id: testUser.id
// Create test user
testUser = await User.create({
email: 'test-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-123',
telegram_chat_id: '987654321',
});
} catch (error) {
expect(error.message).toBe('Database error');
}
// Restore original function
InboxItem.create = originalCreate;
// Clear any existing inbox items
await InboxItem.destroy({ where: {} });
// Stop and reset poller
telegramPoller.stopPolling();
});
test('should handle invalid user data', async () => {
const invalidUser = null;
const addResult = await telegramPoller.addUser(invalidUser);
expect(addResult).toBe(false);
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
test('should handle missing message properties', async () => {
// Test the processing logic with incomplete message data
const incompleteUpdate = {
update_id: 2001,
message: {
// Missing text and other properties
message_id: 601,
chat: { id: 987654321 }
}
};
// The actual processing would skip this message due to missing text
const hasText = incompleteUpdate.message && incompleteUpdate.message.text;
expect(hasText).toBeFalsy();
afterAll(async () => {
console.log = originalConsoleLog;
await sequelize.close();
});
});
});
describe('Database-level Duplicate Prevention', () => {
test('should prevent duplicate inbox items with same content within 30 seconds', async () => {
const messageContent = 'Test duplicate message';
// Create first inbox item
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 123 },
});
// Wait a moment
await new Promise((resolve) => setTimeout(resolve, 100));
// Try to create duplicate item (should be prevented)
const duplicateCheck = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(
Date.now() - 30000
),
},
},
});
expect(duplicateCheck).toBeTruthy();
expect(duplicateCheck.id).toBe(item1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(1);
});
test('should allow duplicate content after 30 seconds', async () => {
const messageContent = 'Test time-based duplicate';
// Create first item with backdated timestamp
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: new Date(Date.now() - 35000), // 35 seconds ago
metadata: { telegram_message_id: 124 },
});
// Create second item (should be allowed)
const item2 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 125 },
});
// Verify both items exist
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id },
});
expect(allItems).toHaveLength(2);
});
test('should allow same content for different users', async () => {
// Create second user
const testUser2 = await User.create({
email: 'test2-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-456',
telegram_chat_id: '123456789',
});
const messageContent = 'Shared message content';
// Create item for first user
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 126 },
});
// Create item for second user (should be allowed)
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser2.id,
metadata: { telegram_message_id: 127 },
});
// Verify both items exist
const allItems = await InboxItem.findAll();
expect(allItems).toHaveLength(2);
const user1Items = allItems.filter(
(item) => item.user_id === testUser.id
);
const user2Items = allItems.filter(
(item) => item.user_id === testUser2.id
);
expect(user1Items).toHaveLength(1);
expect(user2Items).toHaveLength(1);
});
});
describe('Poller State Management', () => {
test('should add and remove users correctly', async () => {
const initialStatus = telegramPoller.getStatus();
expect(initialStatus.usersCount).toBe(0);
expect(initialStatus.running).toBe(false);
// Add user
const addResult = await telegramPoller.addUser(testUser);
expect(addResult).toBe(true);
const statusAfterAdd = telegramPoller.getStatus();
expect(statusAfterAdd.usersCount).toBe(1);
expect(statusAfterAdd.running).toBe(true);
// Remove user
const removeResult = telegramPoller.removeUser(testUser.id);
expect(removeResult).toBe(true);
const statusAfterRemove = telegramPoller.getStatus();
expect(statusAfterRemove.usersCount).toBe(0);
expect(statusAfterRemove.running).toBe(false);
});
test('should not add user without telegram token', async () => {
const userWithoutToken = await User.create({
email: 'no-token@example.com',
password_digest: 'hashedpassword',
// No telegram_bot_token
});
const addResult = await telegramPoller.addUser(userWithoutToken);
expect(addResult).toBe(false);
const status = telegramPoller.getStatus();
expect(status.usersCount).toBe(0);
});
test('should handle adding same user multiple times', async () => {
// Add user first time
await telegramPoller.addUser(testUser);
const status1 = telegramPoller.getStatus();
expect(status1.usersCount).toBe(1);
// Add same user again
await telegramPoller.addUser(testUser);
const status2 = telegramPoller.getStatus();
expect(status2.usersCount).toBe(1); // Should still be 1
});
});
describe('Update Processing Logic', () => {
test('should handle updates with proper ID tracking', async () => {
await telegramPoller.addUser(testUser);
// Simulate updates (this tests the internal logic without actual HTTP calls)
const mockUpdates = [
{
update_id: 1001,
message: {
message_id: 501,
text: 'First message',
chat: { id: 987654321 },
},
},
{
update_id: 1002,
message: {
message_id: 502,
text: 'Second message',
chat: { id: 987654321 },
},
},
];
// Test highest update ID calculation
const highestId = telegramPoller._getHighestUpdateId(mockUpdates);
expect(highestId).toBe(1002);
// Test update key generation (simulating internal logic)
const updateKeys = mockUpdates.map(
(update) => `${testUser.id}-${update.update_id}`
);
expect(updateKeys).toEqual([
`${testUser.id}-1001`,
`${testUser.id}-1002`,
]);
});
test('should properly track processed updates', async () => {
// Test the Set-based tracking logic
const processedUpdates = new Set();
// Add some processed updates
processedUpdates.add('1-1001');
processedUpdates.add('1-1002');
// Test filtering logic
const newUpdates = [
{ update_id: 1001 }, // Should be filtered out
{ update_id: 1002 }, // Should be filtered out
{ update_id: 1003 }, // Should remain
].filter((update) => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(1003);
});
test('should handle memory management for processed updates', async () => {
// Simulate the cleanup logic
const processedUpdates = new Set();
// Add many updates (more than the 1000 limit)
for (let i = 1; i <= 1100; i++) {
processedUpdates.add(`1-${i}`);
}
expect(processedUpdates.size).toBe(1100);
// Simulate cleanup (remove oldest 100)
if (processedUpdates.size > 1000) {
const oldestEntries = Array.from(processedUpdates).slice(
0,
100
);
oldestEntries.forEach((entry) =>
processedUpdates.delete(entry)
);
}
expect(processedUpdates.size).toBe(1000);
expect(processedUpdates.has('1-1')).toBe(false); // Oldest should be removed
expect(processedUpdates.has('1-1100')).toBe(true); // Newest should remain
});
});
describe('Error Handling', () => {
test('should handle database errors gracefully', async () => {
// Mock InboxItem.create to throw an error
const originalCreate = InboxItem.create;
InboxItem.create = jest
.fn()
.mockRejectedValue(new Error('Database error'));
await expect(
InboxItem.create({
content: 'Test error handling',
source: 'telegram',
user_id: testUser.id,
})
).rejects.toThrow('Database error');
// Restore original function
InboxItem.create = originalCreate;
});
test('should handle invalid user data', async () => {
const invalidUser = null;
const addResult = await telegramPoller.addUser(invalidUser);
expect(addResult).toBe(false);
});
test('should handle missing message properties', async () => {
// Test the processing logic with incomplete message data
const incompleteUpdate = {
update_id: 2001,
message: {
// Missing text and other properties
message_id: 601,
chat: { id: 987654321 },
},
};
// The actual processing would skip this message due to missing text
const hasText =
incompleteUpdate.message && incompleteUpdate.message.text;
expect(hasText).toBeFalsy();
});
});
});

View file

@ -4,149 +4,158 @@ const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Telegram Routes', () => {
let user, agent;
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('POST /api/telegram/setup', () => {
it('should setup telegram bot token', async () => {
const botToken = '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
const response = await agent
.post('/api/telegram/setup')
.send({ token: botToken });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Telegram bot token updated successfully');
// Verify token was saved to user
const updatedUser = await User.findByPk(user.id);
expect(updatedUser.telegram_bot_token).toBe(botToken);
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/setup')
.send({ token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-1234567890' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require token parameter', async () => {
const response = await agent
.post('/api/telegram/setup')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token is required.');
});
it('should validate token format', async () => {
const response = await agent
.post('/api/telegram/setup')
.send({ token: 'invalid-token-format' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid Telegram bot token format.');
});
it('should validate token format with correct pattern', async () => {
// Test various invalid formats
const invalidTokens = [
'123456:short',
'notnum:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789-ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789:',
':ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
];
for (const token of invalidTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid Telegram bot token format.');
}
});
it('should accept valid token formats', async () => {
const validTokens = [
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'987654321:XYZabcDEFghiJKLmnoPQRstUVW_09876543',
'555555555:abcdefghijklmnopqrstuvwxyzABCDEFGHI'
];
for (const token of validTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Telegram bot token updated successfully');
}
});
});
describe('POST /api/telegram/start-polling', () => {
beforeEach(async () => {
// Setup bot token first
await user.update({
telegram_bot_token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678'
});
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/start-polling');
describe('POST /api/telegram/setup', () => {
it('should setup telegram bot token', async () => {
const botToken = '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678';
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
const response = await agent
.post('/api/telegram/setup')
.send({ token: botToken });
expect(response.status).toBe(200);
expect(response.body.message).toBe(
'Telegram bot token updated successfully'
);
// Verify token was saved to user
const updatedUser = await User.findByPk(user.id);
expect(updatedUser.telegram_bot_token).toBe(botToken);
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/setup')
.send({
token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-1234567890',
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require token parameter', async () => {
const response = await agent.post('/api/telegram/setup').send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token is required.');
});
it('should validate token format', async () => {
const response = await agent
.post('/api/telegram/setup')
.send({ token: 'invalid-token-format' });
expect(response.status).toBe(400);
expect(response.body.error).toBe(
'Invalid Telegram bot token format.'
);
});
it('should validate token format with correct pattern', async () => {
// Test various invalid formats
const invalidTokens = [
'123456:short',
'notnum:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789-ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'123456789:',
':ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
];
for (const token of invalidTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(400);
expect(response.body.error).toBe(
'Invalid Telegram bot token format.'
);
}
});
it('should accept valid token formats', async () => {
const validTokens = [
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
'987654321:XYZabcDEFghiJKLmnoPQRstUVW_09876543',
'555555555:abcdefghijklmnopqrstuvwxyzABCDEFGHI',
];
for (const token of validTokens) {
const response = await agent
.post('/api/telegram/setup')
.send({ token });
expect(response.status).toBe(200);
expect(response.body.message).toBe(
'Telegram bot token updated successfully'
);
}
});
});
it('should require bot token to be configured', async () => {
// Remove bot token
await user.update({ telegram_bot_token: null });
describe('POST /api/telegram/start-polling', () => {
beforeEach(async () => {
// Setup bot token first
await user.update({
telegram_bot_token:
'123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678',
});
});
const response = await agent
.post('/api/telegram/start-polling');
it('should require authentication', async () => {
const response = await request(app).post(
'/api/telegram/start-polling'
);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token not set.');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require bot token to be configured', async () => {
// Remove bot token
await user.update({ telegram_bot_token: null });
const response = await agent.post('/api/telegram/start-polling');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot token not set.');
});
});
});
describe('POST /api/telegram/stop-polling', () => {
it('should require authentication', async () => {
const response = await request(app)
.post('/api/telegram/stop-polling');
describe('POST /api/telegram/stop-polling', () => {
it('should require authentication', async () => {
const response = await request(app).post(
'/api/telegram/stop-polling'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
describe('GET /api/telegram/polling-status', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/telegram/polling-status');
describe('GET /api/telegram/polling-status', () => {
it('should require authentication', async () => {
const response = await request(app).get(
'/api/telegram/polling-status'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
});
});
});
});

View file

@ -3,178 +3,191 @@ const app = require('../../app');
const { createTestUser } = require('../helpers/testUtils');
describe('URL Routes', () => {
let user, agent;
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/url/title', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/url/title')
.query({ url: 'https://example.com' });
describe('GET /api/url/title', () => {
it('should require authentication', async () => {
const response = await request(app)
.get('/api/url/title')
.query({ url: 'https://example.com' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
it('should require url parameter', async () => {
const response = await agent.get('/api/url/title');
expect(response.status).toBe(400);
expect(response.body.error).toBe('URL parameter is required');
});
it('should return title for valid URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('https://httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(
typeof response.body.title === 'string' ||
response.body.title === null
).toBe(true);
}, 10000);
it('should handle URL without protocol', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(
typeof response.body.title === 'string' ||
response.body.title === null
).toBe(true);
}, 10000);
it('should handle invalid URL gracefully', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'not-a-valid-url' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('not-a-valid-url');
// Title could be null or error message
expect(
response.body.title === null ||
typeof response.body.title === 'string'
).toBe(true);
});
it('should handle unreachable URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://nonexistent-domain-12345.com' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe(
'https://nonexistent-domain-12345.com'
);
expect(response.body.title).toBe(null);
});
});
it('should require url parameter', async () => {
const response = await agent
.get('/api/url/title');
describe('POST /api/url/extract-from-text', () => {
it('should require authentication', async () => {
const response = await request(app)
.post('/api/url/extract-from-text')
.send({ text: 'Check out https://example.com' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('URL parameter is required');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require text parameter', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should extract URL from text and get title', async () => {
const testText =
'Check out this interesting site: https://httpbin.org/html';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
// Title could be extracted or null depending on network conditions
expect(
typeof response.body.title === 'string' ||
response.body.title === null
).toBe(true);
}, 10000);
it('should extract first URL when multiple URLs in text', async () => {
const testText =
'Check out https://httpbin.org/html and also https://example.com';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
}, 10000);
it('should detect URLs without protocol', async () => {
const testText = 'Visit httpbin.org/html for testing';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('httpbin.org/html');
expect(response.body.originalText).toBe(testText);
});
it('should return found false when no URL in text', async () => {
const testText = 'This text has no URLs in it at all';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
it('should handle empty text', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: '' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should handle text with only whitespace', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: ' \n\t ' });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
});
it('should return title for valid URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('https://httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should handle URL without protocol', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'httpbin.org/html' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('httpbin.org/html');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should handle invalid URL gracefully', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'not-a-valid-url' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('not-a-valid-url');
// Title could be null or error message
expect(response.body.title === null || typeof response.body.title === 'string').toBe(true);
});
it('should handle unreachable URL', async () => {
const response = await agent
.get('/api/url/title')
.query({ url: 'https://nonexistent-domain-12345.com' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('url');
expect(response.body).toHaveProperty('title');
expect(response.body.url).toBe('https://nonexistent-domain-12345.com');
expect(response.body.title).toBe(null);
});
});
describe('POST /api/url/extract-from-text', () => {
it('should require authentication', async () => {
const response = await request(app)
.post('/api/url/extract-from-text')
.send({ text: 'Check out https://example.com' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should require text parameter', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should extract URL from text and get title', async () => {
const testText = 'Check out this interesting site: https://httpbin.org/html';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
// Title could be extracted or null depending on network conditions
expect(typeof response.body.title === 'string' || response.body.title === null).toBe(true);
}, 10000);
it('should extract first URL when multiple URLs in text', async () => {
const testText = 'Check out https://httpbin.org/html and also https://example.com';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('https://httpbin.org/html');
expect(response.body.originalText).toBe(testText);
expect(response.body).toHaveProperty('title');
}, 10000);
it('should detect URLs without protocol', async () => {
const testText = 'Visit httpbin.org/html for testing';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(true);
expect(response.body.url).toBe('httpbin.org/html');
expect(response.body.originalText).toBe(testText);
});
it('should return found false when no URL in text', async () => {
const testText = 'This text has no URLs in it at all';
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: testText });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
it('should handle empty text', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: '' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Text parameter is required');
});
it('should handle text with only whitespace', async () => {
const response = await agent
.post('/api/url/extract-from-text')
.send({ text: ' \n\t ' });
expect(response.status).toBe(200);
expect(response.body.found).toBe(false);
});
});
});
});

View file

@ -3,323 +3,334 @@ const path = require('path');
const { User } = require('../../models');
describe('User Create Script', () => {
const scriptPath = path.join(__dirname, '../../scripts/user-create.js');
// Helper function to run the script and capture output
const runUserCreateScript = (args = []) => {
return new Promise((resolve, reject) => {
const child = spawn('node', [scriptPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NODE_ENV: 'test' }
});
const scriptPath = path.join(__dirname, '../../scripts/user-create.js');
let stdout = '';
let stderr = '';
// Helper function to run the script and capture output
const runUserCreateScript = (args = []) => {
return new Promise((resolve, reject) => {
const child = spawn('node', [scriptPath, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NODE_ENV: 'test' },
});
child.stdout.on('data', (data) => {
stdout += data.toString();
});
let stdout = '';
let stderr = '';
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
resolve({
code,
stdout: stdout.trim(),
stderr: stderr.trim()
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({
code,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
});
child.on('error', (error) => {
reject(error);
});
});
});
};
child.on('error', (error) => {
reject(error);
});
});
};
afterEach(async () => {
// Clean up any test users created during tests
await User.destroy({
where: {
email: ['testuser@example.com', 'admin@example.com', 'invalid-email', 'existing@example.com']
}
});
});
describe('Success Cases', () => {
it('should create a new user with valid email and password', async () => {
const email = 'testuser@example.com';
const password = 'securepassword123';
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
expect(result.stdout).toContain(`📧 Email: ${email}`);
expect(result.stdout).toContain('🆔 User ID:');
expect(result.stdout).toContain('📅 Created:');
// Verify user was actually created in database
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
expect(createdUser.email).toBe(email);
expect(createdUser.password_digest).toBeTruthy();
expect(createdUser.password_digest).not.toBe(password); // Should be hashed
afterEach(async () => {
// Clean up any test users created during tests
await User.destroy({
where: {
email: [
'testuser@example.com',
'admin@example.com',
'invalid-email',
'existing@example.com',
],
},
});
});
it('should create user with minimum password length', async () => {
const email = 'testuser2@example.com';
const password = '123456'; // Exactly 6 characters
describe('Success Cases', () => {
it('should create a new user with valid email and password', async () => {
const email = 'testuser@example.com';
const password = 'securepassword123';
const result = await runUserCreateScript([email, password]);
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
expect(result.stdout).toContain(`📧 Email: ${email}`);
expect(result.stdout).toContain('🆔 User ID:');
expect(result.stdout).toContain('📅 Created:');
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Verify user was actually created in database
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
expect(createdUser.email).toBe(email);
expect(createdUser.password_digest).toBeTruthy();
expect(createdUser.password_digest).not.toBe(password); // Should be hashed
});
// Clean up
await User.destroy({ where: { email } });
it('should create user with minimum password length', async () => {
const email = 'testuser2@example.com';
const password = '123456'; // Exactly 6 characters
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Clean up
await User.destroy({ where: { email } });
});
it('should create user with complex email format', async () => {
const email = 'user.name+tag@sub.domain.com';
const password = 'password123';
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Clean up
await User.destroy({ where: { email } });
});
});
it('should create user with complex email format', async () => {
const email = 'user.name+tag@sub.domain.com';
const password = 'password123';
describe('Error Cases', () => {
it('should show usage when no arguments provided', async () => {
const result = await runUserCreateScript([]);
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(1);
expect(result.stderr).toContain(
'❌ Usage: npm run user:create <email> <password>'
);
expect(result.stderr).toContain(
'Example: npm run user:create admin@example.com mypassword123'
);
});
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
it('should show usage when only email provided', async () => {
const result = await runUserCreateScript(['test@example.com']);
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
expect(result.code).toBe(1);
expect(result.stderr).toContain(
'❌ Usage: npm run user:create <email> <password>'
);
});
// Clean up
await User.destroy({ where: { email } });
});
});
it('should show usage when only password provided', async () => {
const result = await runUserCreateScript(['', 'password123']);
describe('Error Cases', () => {
it('should show usage when no arguments provided', async () => {
const result = await runUserCreateScript([]);
expect(result.code).toBe(1);
expect(result.stderr).toContain(
'❌ Usage: npm run user:create <email> <password>'
);
});
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
expect(result.stderr).toContain('Example: npm run user:create admin@example.com mypassword123');
});
it('should reject invalid email format', async () => {
const invalidEmails = [
'invalid-email',
'missing@domain',
'@missing-local.com',
'spaces in@email.com',
'double@@domain.com',
'trailing.dot.@domain.com',
];
it('should show usage when only email provided', async () => {
const result = await runUserCreateScript(['test@example.com']);
for (const email of invalidEmails) {
const result = await runUserCreateScript([
email,
'password123',
]);
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
});
it('should show usage when only password provided', async () => {
const result = await runUserCreateScript(['', 'password123']);
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
});
it('should reject invalid email format', async () => {
const invalidEmails = [
'invalid-email',
'missing@domain',
'@missing-local.com',
'spaces in@email.com',
'double@@domain.com',
'trailing.dot.@domain.com'
];
for (const email of invalidEmails) {
const result = await runUserCreateScript([email, 'password123']);
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Invalid email format');
}
});
it('should reject password shorter than 6 characters', async () => {
const shortPasswords = ['', '1', '12', '123', '1234', '12345'];
for (const password of shortPasswords) {
const result = await runUserCreateScript(['test@example.com', password]);
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Password must be at least 6 characters long');
}
});
it('should reject duplicate email', async () => {
const email = 'existing@example.com';
const password = 'password123';
// Create user first
await User.create({
email,
password_digest: await require('bcrypt').hash(password, 10)
});
// Try to create same user again
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(1);
expect(result.stderr).toContain(`❌ User with email ${email} already exists`);
});
});
describe('Integration with npm script', () => {
it('should work when called via npm run command', async () => {
const email = 'npmtest@example.com';
const password = 'testpassword123';
try {
// This simulates running: npm run user:create npmtest@example.com testpassword123
const output = execSync(
`npm run user:create ${email} ${password}`,
{
cwd: path.join(__dirname, '../..'),
env: { ...process.env, NODE_ENV: 'test' },
encoding: 'utf8',
timeout: 10000
}
);
expect(output).toContain('User created successfully');
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
expect(createdUser.email).toBe(email);
} catch (error) {
// If the command failed, check if it's due to duplicate user (from previous test runs)
if (error.stderr?.includes('already exists')) {
// Clean up and retry
await User.destroy({ where: { email } });
const output = execSync(
`npm run user:create ${email} ${password}`,
{
cwd: path.join(__dirname, '../..'),
env: { ...process.env, NODE_ENV: 'test' },
encoding: 'utf8',
timeout: 10000
expect(result.code).toBe(1);
expect(result.stderr).toContain('❌ Invalid email format');
}
);
});
expect(output).toContain('User created successfully');
} else {
throw error;
}
} finally {
// Clean up
await User.destroy({ where: { email } });
}
});
});
it('should reject password shorter than 6 characters', async () => {
const shortPasswords = ['', '1', '12', '123', '1234', '12345'];
describe('Database Validation', () => {
it('should hash password properly', async () => {
const email = 'hashtest@example.com';
const password = 'plaintextpassword';
for (const password of shortPasswords) {
const result = await runUserCreateScript([
'test@example.com',
password,
]);
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(1);
expect(result.stderr).toContain(
'❌ Password must be at least 6 characters long'
);
}
});
expect(result.code).toBe(0);
it('should reject duplicate email', async () => {
const email = 'existing@example.com';
const password = 'password123';
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Password should be hashed (bcrypt hashes start with $2b$)
expect(createdUser.password_digest).toMatch(/^\$2b\$10\$/);
expect(createdUser.password_digest).not.toBe(password);
expect(createdUser.password_digest.length).toBeGreaterThan(50);
// Create user first
await User.create({
email,
password_digest: await require('bcrypt').hash(password, 10),
});
// Verify the hash is valid
const bcrypt = require('bcrypt');
const isValid = await bcrypt.compare(password, createdUser.password_digest);
expect(isValid).toBe(true);
// Try to create same user again
const result = await runUserCreateScript([email, password]);
// Clean up
await User.destroy({ where: { email } });
expect(result.code).toBe(1);
expect(result.stderr).toContain(
`❌ User with email ${email} already exists`
);
});
});
it('should set correct default values', async () => {
const email = 'defaultstest@example.com';
const password = 'password123';
describe('Integration with npm script', () => {
it('should work when called via npm run command', async () => {
const email = 'npmtest@example.com';
const password = 'testpassword123';
const result = await runUserCreateScript([email, password]);
// Clean up any existing user first
await User.destroy({ where: { email } });
expect(result.code).toBe(0);
try {
// This simulates running: npm run user:create npmtest@example.com testpassword123
const output = execSync(
`npm run user:create ${email} ${password}`,
{
cwd: path.join(__dirname, '../..'),
env: { ...process.env, NODE_ENV: 'test' },
encoding: 'utf8',
timeout: 10000,
}
);
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Check that created_at and updated_at are set
expect(createdUser.created_at).toBeTruthy();
expect(createdUser.updated_at).toBeTruthy();
// Check that it's a valid date
expect(createdUser.created_at instanceof Date).toBe(true);
expect(createdUser.updated_at instanceof Date).toBe(true);
expect(output).toContain('User created successfully');
// Clean up
await User.destroy({ where: { email } });
});
});
describe('Edge Cases', () => {
it('should handle special characters in password', async () => {
const email = 'specialchars@example.com';
const password = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?';
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Verify user was created and password works
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
const bcrypt = require('bcrypt');
const isValid = await bcrypt.compare(password, createdUser.password_digest);
expect(isValid).toBe(true);
// Clean up
await User.destroy({ where: { email } });
// Verify user was created
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
expect(createdUser.email).toBe(email);
} finally {
// Clean up
await User.destroy({ where: { email } });
}
});
});
it('should handle very long email', async () => {
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com';
const password = 'password123';
describe('Database Validation', () => {
it('should hash password properly', async () => {
const email = 'hashtest@example.com';
const password = 'plaintextpassword';
const result = await runUserCreateScript([longEmail, password]);
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
expect(result.code).toBe(0);
// Clean up
await User.destroy({ where: { email: longEmail } });
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Password should be hashed (bcrypt hashes start with $2b$)
expect(createdUser.password_digest).toMatch(/^\$2b\$10\$/);
expect(createdUser.password_digest).not.toBe(password);
expect(createdUser.password_digest.length).toBeGreaterThan(50);
// Verify the hash is valid
const bcrypt = require('bcrypt');
const isValid = await bcrypt.compare(
password,
createdUser.password_digest
);
expect(isValid).toBe(true);
// Clean up
await User.destroy({ where: { email } });
});
it('should set correct default values', async () => {
const email = 'defaultstest@example.com';
const password = 'password123';
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
// Check that created_at and updated_at are set
expect(createdUser.created_at).toBeTruthy();
expect(createdUser.updated_at).toBeTruthy();
// Check that it's a valid date
expect(createdUser.created_at instanceof Date).toBe(true);
expect(createdUser.updated_at instanceof Date).toBe(true);
// Clean up
await User.destroy({ where: { email } });
});
});
it('should handle very long password', async () => {
const email = 'longpassword@example.com';
const password = 'a'.repeat(200); // Very long password
describe('Edge Cases', () => {
it('should handle special characters in password', async () => {
const email = 'specialchars@example.com';
const password = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?';
const result = await runUserCreateScript([email, password]);
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Clean up
await User.destroy({ where: { email } });
// Verify user was created and password works
const createdUser = await User.findOne({ where: { email } });
expect(createdUser).toBeTruthy();
const bcrypt = require('bcrypt');
const isValid = await bcrypt.compare(
password,
createdUser.password_digest
);
expect(isValid).toBe(true);
// Clean up
await User.destroy({ where: { email } });
});
it('should handle very long email', async () => {
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com';
const password = 'password123';
const result = await runUserCreateScript([longEmail, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Clean up
await User.destroy({ where: { email: longEmail } });
});
it('should handle very long password', async () => {
const email = 'longpassword@example.com';
const password = 'a'.repeat(200); // Very long password
const result = await runUserCreateScript([email, password]);
expect(result.code).toBe(0);
expect(result.stdout).toContain('✅ User created successfully');
// Clean up
await User.destroy({ where: { email } });
});
});
});
});
});

View file

@ -4,280 +4,313 @@ const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Users Routes', () => {
let user, agent;
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com'
});
// Create authenticated agent
agent = request.agent(app);
await agent
.post('/api/login')
.send({
email: 'test@example.com',
password: 'password123'
});
});
describe('GET /api/profile', () => {
it('should get user profile', async () => {
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body.id).toBe(user.id);
expect(response.body.email).toBe(user.email);
expect(response.body).toHaveProperty('appearance');
expect(response.body).toHaveProperty('language');
expect(response.body).toHaveProperty('timezone');
expect(response.body).toHaveProperty('avatar_image');
expect(response.body).toHaveProperty('telegram_bot_token');
expect(response.body).toHaveProperty('telegram_chat_id');
expect(response.body).toHaveProperty('task_summary_enabled');
expect(response.body).toHaveProperty('task_summary_frequency');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('PATCH /api/profile', () => {
it('should update user profile', async () => {
const updateData = {
appearance: 'dark',
language: 'es',
timezone: 'UTC',
avatar_image: 'new-avatar.png',
telegram_bot_token: 'new-token'
};
const response = await agent
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(updateData.language);
expect(response.body.timezone).toBe(updateData.timezone);
expect(response.body.avatar_image).toBe(updateData.avatar_image);
expect(response.body.telegram_bot_token).toBe(updateData.telegram_bot_token);
});
it('should allow partial updates', async () => {
const updateData = {
appearance: 'dark'
};
const response = await agent
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(user.language);
});
it('should require authentication', async () => {
const updateData = {
appearance: 'dark'
};
const response = await request(app)
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.patch('/api/profile')
.send({ appearance: 'dark' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('POST /api/profile/task-summary/toggle', () => {
beforeEach(async () => {
await user.update({ task_summary_enabled: false });
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
it('should toggle task summary on', async () => {
const response = await agent.post('/api/profile/task-summary/toggle');
describe('GET /api/profile', () => {
it('should get user profile', async () => {
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.message).toBe('Task summary notifications have been enabled.');
expect(response.status).toBe(200);
expect(response.body.id).toBe(user.id);
expect(response.body.email).toBe(user.email);
expect(response.body).toHaveProperty('appearance');
expect(response.body).toHaveProperty('language');
expect(response.body).toHaveProperty('timezone');
expect(response.body).toHaveProperty('avatar_image');
expect(response.body).toHaveProperty('telegram_bot_token');
expect(response.body).toHaveProperty('telegram_chat_id');
expect(response.body).toHaveProperty('task_summary_enabled');
expect(response.body).toHaveProperty('task_summary_frequency');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get('/api/profile');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should toggle task summary off', async () => {
await user.update({ task_summary_enabled: true });
describe('PATCH /api/profile', () => {
it('should update user profile', async () => {
const updateData = {
appearance: 'dark',
language: 'es',
timezone: 'UTC',
avatar_image: 'new-avatar.png',
telegram_bot_token: 'new-token',
};
const response = await agent.post('/api/profile/task-summary/toggle');
const response = await agent.patch('/api/profile').send(updateData);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(false);
expect(response.body.message).toBe('Task summary notifications have been disabled.');
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(updateData.language);
expect(response.body.timezone).toBe(updateData.timezone);
expect(response.body.avatar_image).toBe(updateData.avatar_image);
expect(response.body.telegram_bot_token).toBe(
updateData.telegram_bot_token
);
});
it('should allow partial updates', async () => {
const updateData = {
appearance: 'dark',
};
const response = await agent.patch('/api/profile').send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe(updateData.appearance);
expect(response.body.language).toBe(user.language);
});
it('should require authentication', async () => {
const updateData = {
appearance: 'dark',
};
const response = await request(app)
.patch('/api/profile')
.send(updateData);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.patch('/api/profile')
.send({ appearance: 'dark' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should require authentication', async () => {
const response = await request(app).post('/api/profile/task-summary/toggle');
describe('POST /api/profile/task-summary/toggle', () => {
beforeEach(async () => {
await user.update({ task_summary_enabled: false });
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
it('should toggle task summary on', async () => {
const response = await agent.post(
'/api/profile/task-summary/toggle'
);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.message).toBe(
'Task summary notifications have been enabled.'
);
});
it('should toggle task summary off', async () => {
await user.update({ task_summary_enabled: true });
const response = await agent.post(
'/api/profile/task-summary/toggle'
);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(false);
expect(response.body.message).toBe(
'Task summary notifications have been disabled.'
);
});
it('should require authentication', async () => {
const response = await request(app).post(
'/api/profile/task-summary/toggle'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.post(
'/api/profile/task-summary/toggle'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
describe('POST /api/profile/task-summary/frequency', () => {
it('should update task summary frequency', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
const response = await agent.post('/api/profile/task-summary/toggle');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body.message).toBe(
'Task summary frequency has been set to daily.'
);
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should require frequency parameter', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({});
describe('POST /api/profile/task-summary/frequency', () => {
it('should update task summary frequency', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Frequency is required.');
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body.message).toBe('Task summary frequency has been set to daily.');
it('should validate frequency value', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'invalid' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid frequency value.');
});
it('should accept valid frequencies', async () => {
const validFrequencies = [
'daily',
'weekdays',
'weekly',
'1h',
'2h',
'4h',
'8h',
'12h',
];
for (const frequency of validFrequencies) {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency });
expect(response.status).toBe(200);
expect(response.body.frequency).toBe(frequency);
}
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should require frequency parameter', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({});
describe('POST /api/profile/task-summary/send-now', () => {
it('should require telegram configuration', async () => {
const response = await agent.post(
'/api/profile/task-summary/send-now'
);
expect(response.status).toBe(400);
expect(response.body.error).toBe('Frequency is required.');
expect(response.status).toBe(400);
expect(response.body.error).toBe(
'Telegram bot is not properly configured.'
);
});
it('should require authentication', async () => {
const response = await request(app).post(
'/api/profile/task-summary/send-now'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.post(
'/api/profile/task-summary/send-now'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should validate frequency value', async () => {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'invalid' });
describe('GET /api/profile/task-summary/status', () => {
it('should get task summary status', async () => {
await user.update({
task_summary_enabled: true,
task_summary_frequency: 'daily',
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid frequency value.');
const response = await agent.get(
'/api/profile/task-summary/status'
);
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body).toHaveProperty('last_run');
expect(response.body).toHaveProperty('next_run');
});
it('should require authentication', async () => {
const response = await request(app).get(
'/api/profile/task-summary/status'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get(
'/api/profile/task-summary/status'
);
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
it('should accept valid frequencies', async () => {
const validFrequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
for (const frequency of validFrequencies) {
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency });
expect(response.status).toBe(200);
expect(response.body.frequency).toBe(frequency);
}
});
it('should require authentication', async () => {
const response = await request(app)
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent
.post('/api/profile/task-summary/frequency')
.send({ frequency: 'daily' });
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('POST /api/profile/task-summary/send-now', () => {
it('should require telegram configuration', async () => {
const response = await agent.post('/api/profile/task-summary/send-now');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Telegram bot is not properly configured.');
});
it('should require authentication', async () => {
const response = await request(app).post('/api/profile/task-summary/send-now');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.post('/api/profile/task-summary/send-now');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
describe('GET /api/profile/task-summary/status', () => {
it('should get task summary status', async () => {
await user.update({
task_summary_enabled: true,
task_summary_frequency: 'daily'
});
const response = await agent.get('/api/profile/task-summary/status');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.enabled).toBe(true);
expect(response.body.frequency).toBe('daily');
expect(response.body).toHaveProperty('last_run');
expect(response.body).toHaveProperty('next_run');
});
it('should require authentication', async () => {
const response = await request(app).get('/api/profile/task-summary/status');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 when session user no longer exists', async () => {
await User.destroy({ where: { id: user.id } });
const response = await agent.get('/api/profile/task-summary/status');
expect(response.status).toBe(401);
expect(response.body.error).toBe('User not found');
});
});
});
});

View file

@ -2,129 +2,137 @@ const { requireAuth } = require('../../../middleware/auth');
const { User } = require('../../../models');
describe('Auth Middleware', () => {
let req, res, next;
let req, res, next;
beforeEach(() => {
req = {
path: '/api/tasks',
session: {}
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};
next = jest.fn();
});
it('should skip authentication for health check', async () => {
req.path = '/api/health';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should skip authentication for login route', async () => {
req.path = '/api/login';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should skip authentication for current_user route', async () => {
req.path = '/api/current_user';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if no session', async () => {
req.session = null;
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if no userId in session', async () => {
req.session = {};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication required' });
expect(next).not.toHaveBeenCalled();
});
it('should return 401 and destroy session if user not found', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
beforeEach(() => {
req = {
path: '/api/tasks',
session: {},
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
next = jest.fn();
});
req.session = {
userId: user.id + 1, // Non-existent user ID
destroy: jest.fn()
};
await requireAuth(req, res, next);
expect(req.session.destroy).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
expect(next).not.toHaveBeenCalled();
});
it('should set currentUser and call next for valid session', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
it('should skip authentication for health check', async () => {
req.path = '/api/health';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
req.session = {
userId: user.id
};
await requireAuth(req, res, next);
expect(req.currentUser).toBeDefined();
expect(req.currentUser.id).toBe(user.id);
expect(req.currentUser.email).toBe(user.email);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
// Mock User.findByPk to throw an error
const originalFindByPk = User.findByPk;
User.findByPk = jest.fn().mockRejectedValue(new Error('Database connection error'));
req.session = {
userId: 123,
destroy: jest.fn()
};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Authentication error' });
expect(next).not.toHaveBeenCalled();
// Restore original methods
User.findByPk = originalFindByPk;
console.error = originalConsoleError;
});
});
it('should skip authentication for login route', async () => {
req.path = '/api/login';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should skip authentication for current_user route', async () => {
req.path = '/api/current_user';
await requireAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should return 401 if no session', async () => {
req.session = null;
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
});
expect(next).not.toHaveBeenCalled();
});
it('should return 401 if no userId in session', async () => {
req.session = {};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication required',
});
expect(next).not.toHaveBeenCalled();
});
it('should return 401 and destroy session if user not found', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
req.session = {
userId: user.id + 1, // Non-existent user ID
destroy: jest.fn(),
};
await requireAuth(req, res, next);
expect(req.session.destroy).toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
expect(next).not.toHaveBeenCalled();
});
it('should set currentUser and call next for valid session', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
req.session = {
userId: user.id,
};
await requireAuth(req, res, next);
expect(req.currentUser).toBeDefined();
expect(req.currentUser.id).toBe(user.id);
expect(req.currentUser.email).toBe(user.email);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
// Mock console.error to suppress expected error log in test output
const originalConsoleError = console.error;
console.error = jest.fn();
// Mock User.findByPk to throw an error
const originalFindByPk = User.findByPk;
User.findByPk = jest
.fn()
.mockRejectedValue(new Error('Database connection error'));
req.session = {
userId: 123,
destroy: jest.fn(),
};
await requireAuth(req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
error: 'Authentication error',
});
expect(next).not.toHaveBeenCalled();
// Restore original methods
User.findByPk = originalFindByPk;
console.error = originalConsoleError;
});
});

View file

@ -1,74 +1,74 @@
const { Area, User } = require('../../../models');
describe('Area Model', () => {
let user;
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create an area with valid data', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects',
user_id: user.id
};
const area = await Area.create(areaData);
expect(area.name).toBe(areaData.name);
expect(area.description).toBe(areaData.description);
expect(area.user_id).toBe(user.id);
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
it('should require name', async () => {
const areaData = {
description: 'Area without name',
user_id: user.id
};
describe('validation', () => {
it('should create an area with valid data', async () => {
const areaData = {
name: 'Work',
description: 'Work related projects',
user_id: user.id,
};
await expect(Area.create(areaData)).rejects.toThrow();
const area = await Area.create(areaData);
expect(area.name).toBe(areaData.name);
expect(area.description).toBe(areaData.description);
expect(area.user_id).toBe(user.id);
});
it('should require name', async () => {
const areaData = {
description: 'Area without name',
user_id: user.id,
};
await expect(Area.create(areaData)).rejects.toThrow();
});
it('should require user_id', async () => {
const areaData = {
name: 'Test Area',
};
await expect(Area.create(areaData)).rejects.toThrow();
});
it('should allow null description', async () => {
const areaData = {
name: 'Test Area',
user_id: user.id,
description: null,
};
const area = await Area.create(areaData);
expect(area.description).toBeNull();
});
});
it('should require user_id', async () => {
const areaData = {
name: 'Test Area'
};
describe('associations', () => {
it('should belong to a user', async () => {
const area = await Area.create({
name: 'Test Area',
user_id: user.id,
});
await expect(Area.create(areaData)).rejects.toThrow();
const areaWithUser = await Area.findByPk(area.id, {
include: [{ model: User }],
});
expect(areaWithUser.User).toBeDefined();
expect(areaWithUser.User.id).toBe(user.id);
expect(areaWithUser.User.email).toBe(user.email);
});
});
it('should allow null description', async () => {
const areaData = {
name: 'Test Area',
user_id: user.id,
description: null
};
const area = await Area.create(areaData);
expect(area.description).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const area = await Area.create({
name: 'Test Area',
user_id: user.id
});
const areaWithUser = await Area.findByPk(area.id, {
include: [{ model: User }]
});
expect(areaWithUser.User).toBeDefined();
expect(areaWithUser.User.id).toBe(user.id);
expect(areaWithUser.User.email).toBe(user.email);
});
});
});
});

View file

@ -1,96 +1,96 @@
const { InboxItem, User } = require('../../../models');
describe('InboxItem Model', () => {
let user;
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create an inbox item with valid data', async () => {
const inboxData = {
content: 'Remember to buy groceries',
status: 'added',
source: 'web',
user_id: user.id
};
const inboxItem = await InboxItem.create(inboxData);
expect(inboxItem.content).toBe(inboxData.content);
expect(inboxItem.status).toBe(inboxData.status);
expect(inboxItem.source).toBe(inboxData.source);
expect(inboxItem.user_id).toBe(user.id);
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
it('should require content', async () => {
const inboxData = {
user_id: user.id
};
describe('validation', () => {
it('should create an inbox item with valid data', async () => {
const inboxData = {
content: 'Remember to buy groceries',
status: 'added',
source: 'web',
user_id: user.id,
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
const inboxItem = await InboxItem.create(inboxData);
expect(inboxItem.content).toBe(inboxData.content);
expect(inboxItem.status).toBe(inboxData.status);
expect(inboxItem.source).toBe(inboxData.source);
expect(inboxItem.user_id).toBe(user.id);
});
it('should require content', async () => {
const inboxData = {
user_id: user.id,
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require user_id', async () => {
const inboxData = {
content: 'Test content',
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require status', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
status: null,
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
it('should require source', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
source: null,
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
});
it('should require user_id', async () => {
const inboxData = {
content: 'Test content'
};
describe('default values', () => {
it('should set correct default values', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id,
});
await expect(InboxItem.create(inboxData)).rejects.toThrow();
expect(inboxItem.status).toBe('added');
expect(inboxItem.source).toBe('tududi');
});
});
it('should require status', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
status: null
};
describe('associations', () => {
it('should belong to a user', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id,
});
await expect(InboxItem.create(inboxData)).rejects.toThrow();
const inboxItemWithUser = await InboxItem.findByPk(inboxItem.id, {
include: [{ model: User }],
});
expect(inboxItemWithUser.User).toBeDefined();
expect(inboxItemWithUser.User.id).toBe(user.id);
expect(inboxItemWithUser.User.email).toBe(user.email);
});
});
it('should require source', async () => {
const inboxData = {
content: 'Test content',
user_id: user.id,
source: null
};
await expect(InboxItem.create(inboxData)).rejects.toThrow();
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
expect(inboxItem.status).toBe('added');
expect(inboxItem.source).toBe('tududi');
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const inboxItem = await InboxItem.create({
content: 'Test content',
user_id: user.id
});
const inboxItemWithUser = await InboxItem.findByPk(inboxItem.id, {
include: [{ model: User }]
});
expect(inboxItemWithUser.User).toBeDefined();
expect(inboxItemWithUser.User.id).toBe(user.id);
expect(inboxItemWithUser.User.email).toBe(user.email);
});
});
});
});

View file

@ -1,102 +1,102 @@
const { Note, User, Project } = require('../../../models');
describe('Note Model', () => {
let user, project;
let user, project;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
});
project = await Project.create({
name: 'Test Project',
user_id: user.id
});
});
describe('validation', () => {
it('should create a note with valid data', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
user_id: user.id,
project_id: project.id,
};
describe('validation', () => {
it('should create a note with valid data', async () => {
const noteData = {
title: 'Test Note',
content: 'This is a test note content',
user_id: user.id,
project_id: project.id
};
const note = await Note.create(noteData);
const note = await Note.create(noteData);
expect(note.title).toBe(noteData.title);
expect(note.content).toBe(noteData.content);
expect(note.user_id).toBe(user.id);
expect(note.project_id).toBe(project.id);
expect(note.title).toBe(noteData.title);
expect(note.content).toBe(noteData.content);
expect(note.user_id).toBe(user.id);
expect(note.project_id).toBe(project.id);
});
it('should require user_id', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content',
};
await expect(Note.create(noteData)).rejects.toThrow();
});
it('should allow title and content to be null', async () => {
const noteData = {
title: null,
content: null,
user_id: user.id,
};
const note = await Note.create(noteData);
expect(note.title).toBeNull();
expect(note.content).toBeNull();
});
it('should allow project_id to be null', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: null,
};
const note = await Note.create(noteData);
expect(note.project_id).toBeNull();
});
});
it('should require user_id', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content'
};
describe('associations', () => {
it('should belong to a user', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id,
});
await expect(Note.create(noteData)).rejects.toThrow();
const noteWithUser = await Note.findByPk(note.id, {
include: [{ model: User }],
});
expect(noteWithUser.User).toBeDefined();
expect(noteWithUser.User.id).toBe(user.id);
expect(noteWithUser.User.email).toBe(user.email);
});
it('should belong to a project', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id,
project_id: project.id,
});
const noteWithProject = await Note.findByPk(note.id, {
include: [{ model: Project }],
});
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.id).toBe(project.id);
expect(noteWithProject.Project.name).toBe(project.name);
});
});
it('should allow title and content to be null', async () => {
const noteData = {
title: null,
content: null,
user_id: user.id
};
const note = await Note.create(noteData);
expect(note.title).toBeNull();
expect(note.content).toBeNull();
});
it('should allow project_id to be null', async () => {
const noteData = {
title: 'Test Note',
content: 'Test content',
user_id: user.id,
project_id: null
};
const note = await Note.create(noteData);
expect(note.project_id).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id
});
const noteWithUser = await Note.findByPk(note.id, {
include: [{ model: User }]
});
expect(noteWithUser.User).toBeDefined();
expect(noteWithUser.User.id).toBe(user.id);
expect(noteWithUser.User.email).toBe(user.email);
});
it('should belong to a project', async () => {
const note = await Note.create({
title: 'Test Note',
user_id: user.id,
project_id: project.id
});
const noteWithProject = await Note.findByPk(note.id, {
include: [{ model: Project }]
});
expect(noteWithProject.Project).toBeDefined();
expect(noteWithProject.Project.id).toBe(project.id);
expect(noteWithProject.Project.name).toBe(project.name);
});
});
});
});

View file

@ -1,141 +1,141 @@
const { Project, User, Area } = require('../../../models');
describe('Project Model', () => {
let user, area;
let user, area;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
area = await Area.create({
name: 'Work',
user_id: user.id
});
});
describe('validation', () => {
it('should create a project with valid data', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
user_id: user.id,
area_id: area.id
};
const project = await Project.create(projectData);
expect(project.name).toBe(projectData.name);
expect(project.description).toBe(projectData.description);
expect(project.active).toBe(projectData.active);
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
expect(project.priority).toBe(projectData.priority);
expect(project.user_id).toBe(user.id);
expect(project.area_id).toBe(area.id);
});
it('should require name', async () => {
const projectData = {
description: 'Project without name',
user_id: user.id
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should require user_id', async () => {
const projectData = {
name: 'Test Project'
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const projectData = {
name: 'Test Project',
user_id: user.id,
priority: 5
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should allow valid priority values', async () => {
for (let priority of [0, 1, 2]) {
const project = await Project.create({
name: `Test Project ${priority}`,
user_id: user.id,
priority: priority
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
expect(project.priority).toBe(priority);
}
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id
});
expect(project.active).toBe(false);
expect(project.pin_to_sidebar).toBe(false);
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
description: null,
priority: null,
due_date_at: null,
area_id: null
});
expect(project.description).toBeNull();
expect(project.priority).toBeNull();
expect(project.due_date_at).toBeNull();
expect(project.area_id).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id
});
const projectWithUser = await Project.findByPk(project.id, {
include: [{ model: User }]
});
expect(projectWithUser.User).toBeDefined();
expect(projectWithUser.User.id).toBe(user.id);
area = await Area.create({
name: 'Work',
user_id: user.id,
});
});
it('should belong to an area', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
area_id: area.id
});
describe('validation', () => {
it('should create a project with valid data', async () => {
const projectData = {
name: 'Test Project',
description: 'Test Description',
active: true,
pin_to_sidebar: false,
priority: 1,
user_id: user.id,
area_id: area.id,
};
const projectWithArea = await Project.findByPk(project.id, {
include: [{ model: Area }]
});
const project = await Project.create(projectData);
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.id).toBe(area.id);
expect(project.name).toBe(projectData.name);
expect(project.description).toBe(projectData.description);
expect(project.active).toBe(projectData.active);
expect(project.pin_to_sidebar).toBe(projectData.pin_to_sidebar);
expect(project.priority).toBe(projectData.priority);
expect(project.user_id).toBe(user.id);
expect(project.area_id).toBe(area.id);
});
it('should require name', async () => {
const projectData = {
description: 'Project without name',
user_id: user.id,
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should require user_id', async () => {
const projectData = {
name: 'Test Project',
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const projectData = {
name: 'Test Project',
user_id: user.id,
priority: 5,
};
await expect(Project.create(projectData)).rejects.toThrow();
});
it('should allow valid priority values', async () => {
for (let priority of [0, 1, 2]) {
const project = await Project.create({
name: `Test Project ${priority}`,
user_id: user.id,
priority: priority,
});
expect(project.priority).toBe(priority);
}
});
});
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
expect(project.active).toBe(false);
expect(project.pin_to_sidebar).toBe(false);
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
description: null,
priority: null,
due_date_at: null,
area_id: null,
});
expect(project.description).toBeNull();
expect(project.priority).toBeNull();
expect(project.due_date_at).toBeNull();
expect(project.area_id).toBeNull();
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
const projectWithUser = await Project.findByPk(project.id, {
include: [{ model: User }],
});
expect(projectWithUser.User).toBeDefined();
expect(projectWithUser.User.id).toBe(user.id);
});
it('should belong to an area', async () => {
const project = await Project.create({
name: 'Test Project',
user_id: user.id,
area_id: area.id,
});
const projectWithArea = await Project.findByPk(project.id, {
include: [{ model: Area }],
});
expect(projectWithArea.Area).toBeDefined();
expect(projectWithArea.Area.id).toBe(area.id);
});
});
});

View file

@ -1,83 +1,83 @@
const { Tag, User } = require('../../../models');
describe('Tag Model', () => {
let user;
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create a tag with valid data', async () => {
const tagData = {
name: 'work',
user_id: user.id
};
const tag = await Tag.create(tagData);
expect(tag.name).toBe(tagData.name);
expect(tag.user_id).toBe(user.id);
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
it('should require name', async () => {
const tagData = {
user_id: user.id
};
describe('validation', () => {
it('should create a tag with valid data', async () => {
const tagData = {
name: 'work',
user_id: user.id,
};
await expect(Tag.create(tagData)).rejects.toThrow();
const tag = await Tag.create(tagData);
expect(tag.name).toBe(tagData.name);
expect(tag.user_id).toBe(user.id);
});
it('should require name', async () => {
const tagData = {
user_id: user.id,
};
await expect(Tag.create(tagData)).rejects.toThrow();
});
it('should require user_id', async () => {
const tagData = {
name: 'work',
};
await expect(Tag.create(tagData)).rejects.toThrow();
});
it('should allow multiple tags with same name for different users', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
const tag1 = await Tag.create({
name: 'work',
user_id: user.id,
});
const tag2 = await Tag.create({
name: 'work',
user_id: otherUser.id,
});
expect(tag1.name).toBe('work');
expect(tag2.name).toBe('work');
expect(tag1.user_id).toBe(user.id);
expect(tag2.user_id).toBe(otherUser.id);
});
});
it('should require user_id', async () => {
const tagData = {
name: 'work'
};
describe('associations', () => {
it('should belong to a user', async () => {
const tag = await Tag.create({
name: 'work',
user_id: user.id,
});
await expect(Tag.create(tagData)).rejects.toThrow();
const tagWithUser = await Tag.findByPk(tag.id, {
include: [{ model: User }],
});
expect(tagWithUser.User).toBeDefined();
expect(tagWithUser.User.id).toBe(user.id);
expect(tagWithUser.User.email).toBe(user.email);
});
});
it('should allow multiple tags with same name for different users', async () => {
const bcrypt = require('bcrypt');
const otherUser = await User.create({
email: 'other@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
const tag1 = await Tag.create({
name: 'work',
user_id: user.id
});
const tag2 = await Tag.create({
name: 'work',
user_id: otherUser.id
});
expect(tag1.name).toBe('work');
expect(tag2.name).toBe('work');
expect(tag1.user_id).toBe(user.id);
expect(tag2.user_id).toBe(otherUser.id);
});
});
describe('associations', () => {
it('should belong to a user', async () => {
const tag = await Tag.create({
name: 'work',
user_id: user.id
});
const tagWithUser = await Tag.findByPk(tag.id, {
include: [{ model: User }]
});
expect(tagWithUser.User).toBeDefined();
expect(tagWithUser.User.id).toBe(user.id);
expect(tagWithUser.User.email).toBe(user.email);
});
});
});
});

View file

@ -1,183 +1,183 @@
const { Task, User } = require('../../../models');
describe('Task Model', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
describe('validation', () => {
it('should create a task with valid data', async () => {
const taskData = {
name: 'Test Task',
description: 'Test Description',
user_id: user.id
};
const task = await Task.create(taskData);
expect(task.name).toBe(taskData.name);
expect(task.description).toBe(taskData.description);
expect(task.user_id).toBe(user.id);
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
it('should require name', async () => {
const taskData = {
user_id: user.id
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should require user_id', async () => {
const taskData = {
name: 'Test Task'
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
priority: 5
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate status range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
status: 10
};
await expect(Task.create(taskData)).rejects.toThrow();
});
});
describe('constants', () => {
it('should have correct priority constants', () => {
expect(Task.PRIORITY.LOW).toBe(0);
expect(Task.PRIORITY.MEDIUM).toBe(1);
expect(Task.PRIORITY.HIGH).toBe(2);
});
it('should have correct status constants', () => {
expect(Task.STATUS.NOT_STARTED).toBe(0);
expect(Task.STATUS.IN_PROGRESS).toBe(1);
expect(Task.STATUS.DONE).toBe(2);
expect(Task.STATUS.ARCHIVED).toBe(3);
expect(Task.STATUS.WAITING).toBe(4);
});
});
describe('instance methods', () => {
let task;
let user;
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id
});
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
it('should return correct priority name', async () => {
task.priority = Task.PRIORITY.LOW;
expect(Task.getPriorityName(task.priority)).toBe('low');
describe('validation', () => {
it('should create a task with valid data', async () => {
const taskData = {
name: 'Test Task',
description: 'Test Description',
user_id: user.id,
};
task.priority = Task.PRIORITY.MEDIUM;
expect(Task.getPriorityName(task.priority)).toBe('medium');
const task = await Task.create(taskData);
task.priority = Task.PRIORITY.HIGH;
expect(Task.getPriorityName(task.priority)).toBe('high');
expect(task.name).toBe(taskData.name);
expect(task.description).toBe(taskData.description);
expect(task.user_id).toBe(user.id);
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
it('should require name', async () => {
const taskData = {
user_id: user.id,
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should require user_id', async () => {
const taskData = {
name: 'Test Task',
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate priority range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
priority: 5,
};
await expect(Task.create(taskData)).rejects.toThrow();
});
it('should validate status range', async () => {
const taskData = {
name: 'Test Task',
user_id: user.id,
status: 10,
};
await expect(Task.create(taskData)).rejects.toThrow();
});
});
it('should return correct status name', async () => {
task.status = Task.STATUS.NOT_STARTED;
expect(Task.getStatusName(task.status)).toBe('not_started');
describe('constants', () => {
it('should have correct priority constants', () => {
expect(Task.PRIORITY.LOW).toBe(0);
expect(Task.PRIORITY.MEDIUM).toBe(1);
expect(Task.PRIORITY.HIGH).toBe(2);
});
task.status = Task.STATUS.IN_PROGRESS;
expect(Task.getStatusName(task.status)).toBe('in_progress');
task.status = Task.STATUS.DONE;
expect(Task.getStatusName(task.status)).toBe('done');
task.status = Task.STATUS.ARCHIVED;
expect(Task.getStatusName(task.status)).toBe('archived');
task.status = Task.STATUS.WAITING;
expect(Task.getStatusName(task.status)).toBe('waiting');
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id
});
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
description: null,
due_date: null,
note: null,
recurrence_interval: null,
recurrence_end_date: null,
last_generated_date: null,
project_id: null
});
expect(task.description).toBeNull();
expect(task.due_date).toBeNull();
expect(task.note).toBeNull();
expect(task.recurrence_interval).toBeNull();
expect(task.recurrence_end_date).toBeNull();
expect(task.last_generated_date).toBeNull();
expect(task.project_id).toBeNull();
it('should have correct status constants', () => {
expect(Task.STATUS.NOT_STARTED).toBe(0);
expect(Task.STATUS.IN_PROGRESS).toBe(1);
expect(Task.STATUS.DONE).toBe(2);
expect(Task.STATUS.ARCHIVED).toBe(3);
expect(Task.STATUS.WAITING).toBe(4);
});
});
it('should accept optional field values', async () => {
const dueDate = new Date();
const task = await Task.create({
name: 'Test Task',
description: 'Test Description',
due_date: dueDate,
today: true,
priority: Task.PRIORITY.HIGH,
status: Task.STATUS.IN_PROGRESS,
note: 'Test Note',
user_id: user.id
});
describe('instance methods', () => {
let task;
expect(task.description).toBe('Test Description');
expect(task.due_date).toEqual(dueDate);
expect(task.today).toBe(true);
expect(task.priority).toBe(Task.PRIORITY.HIGH);
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
expect(task.note).toBe('Test Note');
beforeEach(async () => {
task = await Task.create({
name: 'Test Task',
user_id: user.id,
});
});
it('should return correct priority name', async () => {
task.priority = Task.PRIORITY.LOW;
expect(Task.getPriorityName(task.priority)).toBe('low');
task.priority = Task.PRIORITY.MEDIUM;
expect(Task.getPriorityName(task.priority)).toBe('medium');
task.priority = Task.PRIORITY.HIGH;
expect(Task.getPriorityName(task.priority)).toBe('high');
});
it('should return correct status name', async () => {
task.status = Task.STATUS.NOT_STARTED;
expect(Task.getStatusName(task.status)).toBe('not_started');
task.status = Task.STATUS.IN_PROGRESS;
expect(Task.getStatusName(task.status)).toBe('in_progress');
task.status = Task.STATUS.DONE;
expect(Task.getStatusName(task.status)).toBe('done');
task.status = Task.STATUS.ARCHIVED;
expect(Task.getStatusName(task.status)).toBe('archived');
task.status = Task.STATUS.WAITING;
expect(Task.getStatusName(task.status)).toBe('waiting');
});
});
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
});
expect(task.today).toBe(false);
expect(task.priority).toBe(0);
expect(task.status).toBe(0);
expect(task.recurrence_type).toBe('none');
});
});
describe('optional fields', () => {
it('should allow optional fields to be null', async () => {
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
description: null,
due_date: null,
note: null,
recurrence_interval: null,
recurrence_end_date: null,
last_generated_date: null,
project_id: null,
});
expect(task.description).toBeNull();
expect(task.due_date).toBeNull();
expect(task.note).toBeNull();
expect(task.recurrence_interval).toBeNull();
expect(task.recurrence_end_date).toBeNull();
expect(task.last_generated_date).toBeNull();
expect(task.project_id).toBeNull();
});
it('should accept optional field values', async () => {
const dueDate = new Date();
const task = await Task.create({
name: 'Test Task',
description: 'Test Description',
due_date: dueDate,
today: true,
priority: Task.PRIORITY.HIGH,
status: Task.STATUS.IN_PROGRESS,
note: 'Test Note',
user_id: user.id,
});
expect(task.description).toBe('Test Description');
expect(task.due_date).toEqual(dueDate);
expect(task.today).toBe(true);
expect(task.priority).toBe(Task.PRIORITY.HIGH);
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
expect(task.note).toBe('Test Note');
});
});
});

View file

@ -1,137 +1,152 @@
const { User } = require('../../../models');
describe('User Model', () => {
describe('validation', () => {
it('should create a user with valid data', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
};
describe('validation', () => {
it('should create a user with valid data', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
};
const user = await User.create(userData);
expect(user.email).toBe(userData.email);
expect(user.password_digest).toBeDefined();
expect(user.password_digest).toBe(userData.password_digest);
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
const user = await User.create(userData);
expect(user.email).toBe(userData.email);
expect(user.password_digest).toBeDefined();
expect(user.password_digest).toBe(userData.password_digest);
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
});
it('should require email', async () => {
const userData = {
password: 'password123',
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should require valid email format', async () => {
const userData = {
email: 'invalid-email',
password: 'password123',
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should require unique email', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
};
await User.create(userData);
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate appearance values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
appearance: 'invalid',
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate task_summary_frequency values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
task_summary_frequency: 'invalid',
};
await expect(User.create(userData)).rejects.toThrow();
});
});
it('should require email', async () => {
const userData = {
password: 'password123'
};
describe('password methods', () => {
let user;
await expect(User.create(userData)).rejects.toThrow();
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
});
it('should hash password on creation', async () => {
expect(user.password_digest).toBeDefined();
expect(user.password_digest).not.toBe('password123');
});
it('should check password correctly', async () => {
const isValid = await User.checkPassword(
'password123',
user.password_digest
);
expect(isValid).toBe(true);
const isInvalid = await User.checkPassword(
'wrongpassword',
user.password_digest
);
expect(isInvalid).toBe(false);
});
it('should set new password using setPassword method', async () => {
const oldPasswordDigest = user.password_digest;
const newPasswordDigest = await User.hashPassword('newpassword');
user.password_digest = newPasswordDigest;
await user.save();
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await User.checkPassword(
'newpassword',
user.password_digest
);
expect(isValidNew).toBe(true);
const isValidOld = await User.checkPassword(
'password123',
user.password_digest
);
expect(isValidOld).toBe(false);
});
it('should hash password on update', async () => {
const oldPasswordDigest = user.password_digest;
user.password = 'newpassword';
await user.save();
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await User.checkPassword(
'newpassword',
user.password_digest
);
expect(isValidNew).toBe(true);
});
});
it('should require valid email format', async () => {
const userData = {
email: 'invalid-email',
password: 'password123'
};
describe('default values', () => {
it('should set correct default values', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
});
await expect(User.create(userData)).rejects.toThrow();
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
expect(user.task_summary_enabled).toBe(false);
expect(user.task_summary_frequency).toBe('daily');
});
});
it('should require unique email', async () => {
const bcrypt = require('bcrypt');
const userData = {
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
};
await User.create(userData);
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate appearance values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
appearance: 'invalid'
};
await expect(User.create(userData)).rejects.toThrow();
});
it('should validate task_summary_frequency values', async () => {
const userData = {
email: 'test@example.com',
password: 'password123',
task_summary_frequency: 'invalid'
};
await expect(User.create(userData)).rejects.toThrow();
});
});
describe('password methods', () => {
let user;
beforeEach(async () => {
const bcrypt = require('bcrypt');
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
});
it('should hash password on creation', async () => {
expect(user.password_digest).toBeDefined();
expect(user.password_digest).not.toBe('password123');
});
it('should check password correctly', async () => {
const isValid = await User.checkPassword('password123', user.password_digest);
expect(isValid).toBe(true);
const isInvalid = await User.checkPassword('wrongpassword', user.password_digest);
expect(isInvalid).toBe(false);
});
it('should set new password using setPassword method', async () => {
const oldPasswordDigest = user.password_digest;
const newPasswordDigest = await User.hashPassword('newpassword');
user.password_digest = newPasswordDigest;
await user.save();
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await User.checkPassword('newpassword', user.password_digest);
expect(isValidNew).toBe(true);
const isValidOld = await User.checkPassword('password123', user.password_digest);
expect(isValidOld).toBe(false);
});
it('should hash password on update', async () => {
const oldPasswordDigest = user.password_digest;
user.password = 'newpassword';
await user.save();
expect(user.password_digest).not.toBe(oldPasswordDigest);
const isValidNew = await User.checkPassword('newpassword', user.password_digest);
expect(isValidNew).toBe(true);
});
});
describe('default values', () => {
it('should set correct default values', async () => {
const bcrypt = require('bcrypt');
const user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10)
});
expect(user.appearance).toBe('light');
expect(user.language).toBe('en');
expect(user.timezone).toBe('UTC');
expect(user.task_summary_enabled).toBe(false);
expect(user.task_summary_frequency).toBe('daily');
});
});
});
});

View file

@ -4,110 +4,127 @@ const quotesService = require('../../../services/quotesService');
const taskSummaryService = require('../../../services/taskSummaryService');
describe('Functional Services', () => {
describe('TaskScheduler', () => {
it('should export functional interface', () => {
expect(typeof taskScheduler.initialize).toBe('function');
expect(typeof taskScheduler.stop).toBe('function');
expect(typeof taskScheduler.restart).toBe('function');
expect(typeof taskScheduler.getStatus).toBe('function');
describe('TaskScheduler', () => {
it('should export functional interface', () => {
expect(typeof taskScheduler.initialize).toBe('function');
expect(typeof taskScheduler.stop).toBe('function');
expect(typeof taskScheduler.restart).toBe('function');
expect(typeof taskScheduler.getStatus).toBe('function');
});
it('should have pure helper functions for testing', () => {
expect(typeof taskScheduler._createSchedulerState).toBe('function');
expect(typeof taskScheduler._shouldDisableScheduler).toBe(
'function'
);
expect(typeof taskScheduler._getCronExpression).toBe('function');
});
it('should return proper cron expressions', () => {
expect(taskScheduler._getCronExpression('daily')).toBe('0 7 * * *');
expect(taskScheduler._getCronExpression('weekly')).toBe(
'0 7 * * 1'
);
expect(taskScheduler._getCronExpression('1h')).toBe('0 * * * *');
});
});
it('should have pure helper functions for testing', () => {
expect(typeof taskScheduler._createSchedulerState).toBe('function');
expect(typeof taskScheduler._shouldDisableScheduler).toBe('function');
expect(typeof taskScheduler._getCronExpression).toBe('function');
describe('TelegramPoller', () => {
it('should export functional interface', () => {
expect(typeof telegramPoller.addUser).toBe('function');
expect(typeof telegramPoller.removeUser).toBe('function');
expect(typeof telegramPoller.getStatus).toBe('function');
expect(typeof telegramPoller.sendTelegramMessage).toBe('function');
});
it('should have pure helper functions for testing', () => {
expect(typeof telegramPoller._userExistsInList).toBe('function');
expect(typeof telegramPoller._addUserToList).toBe('function');
expect(typeof telegramPoller._removeUserFromList).toBe('function');
expect(typeof telegramPoller._createMessageParams).toBe('function');
});
it('should handle user list operations functionally', () => {
const users = [{ id: 1 }, { id: 2 }];
const newUser = { id: 3 };
expect(telegramPoller._userExistsInList(users, 1)).toBe(true);
expect(telegramPoller._userExistsInList(users, 3)).toBe(false);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(3);
expect(users).toHaveLength(2); // Original array unchanged
const filteredUsers = telegramPoller._removeUserFromList(
updatedUsers,
2
);
expect(filteredUsers).toHaveLength(2);
expect(filteredUsers.find((u) => u.id === 2)).toBeUndefined();
});
});
it('should return proper cron expressions', () => {
expect(taskScheduler._getCronExpression('daily')).toBe('0 7 * * *');
expect(taskScheduler._getCronExpression('weekly')).toBe('0 7 * * 1');
expect(taskScheduler._getCronExpression('1h')).toBe('0 * * * *');
});
});
describe('QuotesService', () => {
it('should export functional interface', () => {
expect(typeof quotesService.getRandomQuote).toBe('function');
expect(typeof quotesService.getAllQuotes).toBe('function');
expect(typeof quotesService.getQuotesCount).toBe('function');
expect(typeof quotesService.reloadQuotes).toBe('function');
});
describe('TelegramPoller', () => {
it('should export functional interface', () => {
expect(typeof telegramPoller.addUser).toBe('function');
expect(typeof telegramPoller.removeUser).toBe('function');
expect(typeof telegramPoller.getStatus).toBe('function');
expect(typeof telegramPoller.sendTelegramMessage).toBe('function');
it('should have pure helper functions for testing', () => {
expect(typeof quotesService._createDefaultQuotes).toBe('function');
expect(typeof quotesService._getRandomIndex).toBe('function');
expect(typeof quotesService._validateQuotesData).toBe('function');
});
it('should validate quotes data structure correctly', () => {
const validData = { quotes: ['quote1', 'quote2'] };
const invalidData1 = { quotes: 'not-array' };
const invalidData2 = { notQuotes: ['quote1'] };
const invalidData3 = null;
expect(quotesService._validateQuotesData(validData)).toBe(true);
expect(quotesService._validateQuotesData(invalidData1)).toBe(false);
expect(quotesService._validateQuotesData(invalidData2)).toBe(false);
expect(quotesService._validateQuotesData(invalidData3)).toBe(false);
});
});
it('should have pure helper functions for testing', () => {
expect(typeof telegramPoller._userExistsInList).toBe('function');
expect(typeof telegramPoller._addUserToList).toBe('function');
expect(typeof telegramPoller._removeUserFromList).toBe('function');
expect(typeof telegramPoller._createMessageParams).toBe('function');
});
describe('TaskSummaryService', () => {
it('should export functional interface', () => {
expect(typeof taskSummaryService.generateSummaryForUser).toBe(
'function'
);
expect(typeof taskSummaryService.sendSummaryToUser).toBe(
'function'
);
expect(typeof taskSummaryService.calculateNextRunTime).toBe(
'function'
);
});
it('should handle user list operations functionally', () => {
const users = [{ id: 1 }, { id: 2 }];
const newUser = { id: 3 };
expect(telegramPoller._userExistsInList(users, 1)).toBe(true);
expect(telegramPoller._userExistsInList(users, 3)).toBe(false);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(3);
expect(users).toHaveLength(2); // Original array unchanged
const filteredUsers = telegramPoller._removeUserFromList(updatedUsers, 2);
expect(filteredUsers).toHaveLength(2);
expect(filteredUsers.find(u => u.id === 2)).toBeUndefined();
});
});
it('should have pure helper functions for testing', () => {
expect(typeof taskSummaryService._escapeMarkdown).toBe('function');
expect(typeof taskSummaryService._getPriorityEmoji).toBe(
'function'
);
expect(typeof taskSummaryService._buildTaskSection).toBe(
'function'
);
});
describe('QuotesService', () => {
it('should export functional interface', () => {
expect(typeof quotesService.getRandomQuote).toBe('function');
expect(typeof quotesService.getAllQuotes).toBe('function');
expect(typeof quotesService.getQuotesCount).toBe('function');
expect(typeof quotesService.reloadQuotes).toBe('function');
});
it('should escape markdown correctly', () => {
const text = 'Task with *bold* and _italic_ text';
const escaped = taskSummaryService._escapeMarkdown(text);
expect(escaped).toBe('Task with \\*bold\\* and \\_italic\\_ text');
});
it('should have pure helper functions for testing', () => {
expect(typeof quotesService._createDefaultQuotes).toBe('function');
expect(typeof quotesService._getRandomIndex).toBe('function');
expect(typeof quotesService._validateQuotesData).toBe('function');
it('should return correct priority emojis', () => {
expect(taskSummaryService._getPriorityEmoji(0)).toBe('🟢'); // low
expect(taskSummaryService._getPriorityEmoji(1)).toBe('🟠'); // medium
expect(taskSummaryService._getPriorityEmoji(2)).toBe('🔴'); // high
expect(taskSummaryService._getPriorityEmoji(99)).toBe('⚪'); // unknown
});
});
it('should validate quotes data structure correctly', () => {
const validData = { quotes: ['quote1', 'quote2'] };
const invalidData1 = { quotes: 'not-array' };
const invalidData2 = { notQuotes: ['quote1'] };
const invalidData3 = null;
expect(quotesService._validateQuotesData(validData)).toBe(true);
expect(quotesService._validateQuotesData(invalidData1)).toBe(false);
expect(quotesService._validateQuotesData(invalidData2)).toBe(false);
expect(quotesService._validateQuotesData(invalidData3)).toBe(false);
});
});
describe('TaskSummaryService', () => {
it('should export functional interface', () => {
expect(typeof taskSummaryService.generateSummaryForUser).toBe('function');
expect(typeof taskSummaryService.sendSummaryToUser).toBe('function');
expect(typeof taskSummaryService.calculateNextRunTime).toBe('function');
});
it('should have pure helper functions for testing', () => {
expect(typeof taskSummaryService._escapeMarkdown).toBe('function');
expect(typeof taskSummaryService._getPriorityEmoji).toBe('function');
expect(typeof taskSummaryService._buildTaskSection).toBe('function');
});
it('should escape markdown correctly', () => {
const text = 'Task with *bold* and _italic_ text';
const escaped = taskSummaryService._escapeMarkdown(text);
expect(escaped).toBe('Task with \\*bold\\* and \\_italic\\_ text');
});
it('should return correct priority emojis', () => {
expect(taskSummaryService._getPriorityEmoji(0)).toBe('🟢'); // low
expect(taskSummaryService._getPriorityEmoji(1)).toBe('🟠'); // medium
expect(taskSummaryService._getPriorityEmoji(2)).toBe('🔴'); // high
expect(taskSummaryService._getPriorityEmoji(99)).toBe('⚪'); // unknown
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -2,424 +2,550 @@ const RecurringTaskService = require('../../../services/recurringTaskService');
const { Task } = require('../../../models');
describe('RecurringTaskService', () => {
describe('Date Calculation Tests', () => {
describe('calculateNextDueDate', () => {
// Test daily recurrence
describe('Daily recurrence', () => {
it('should calculate next daily occurrence correctly', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
describe('Date Calculation Tests', () => {
describe('calculateNextDueDate', () => {
// Test daily recurrence
describe('Daily recurrence', () => {
it('should calculate next daily occurrence correctly', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
});
it('should handle custom daily intervals', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 3,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-18T10:00:00Z'));
});
it('should handle edge case with zero interval', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 0,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
});
});
// Test weekly recurrence
describe('Weekly recurrence', () => {
it('should calculate next weekly occurrence correctly', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-22T10:00:00Z'));
});
it('should handle weekly with specific weekday', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-20T10:00:00Z')); // Next Monday
});
it('should handle bi-weekly recurrence', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 2,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-29T10:00:00Z'));
});
});
// Test monthly recurrence
describe('Monthly recurrence', () => {
it('should calculate next monthly occurrence correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-02-15T10:00:00Z'));
});
it('should handle month boundaries correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-31T10:00:00Z'); // January 31st
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// February only has 28 days in 2025, should go to Feb 28
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2024-01-29T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle custom monthly intervals', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 3,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-04-15T10:00:00Z'));
});
it('should handle monthly with specific day', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: 5,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-02-05T10:00:00Z'));
});
});
// Test monthly weekday recurrence
describe('Monthly weekday recurrence', () => {
it('should calculate first Monday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
recurrence_week_of_month: 1, // First week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// First Monday of February 2025 is February 3rd
expect(nextDate).toEqual(new Date('2025-02-03T10:00:00Z'));
});
it('should calculate last Friday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 5, // Friday
recurrence_week_of_month: 5, // Last week (represented as 5)
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last Friday of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle third Wednesday of month', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 3, // Wednesday
recurrence_week_of_month: 3, // Third week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Third Wednesday of February 2025 is February 19th
expect(nextDate).toEqual(new Date('2025-02-19T10:00:00Z'));
});
});
// Test monthly last day recurrence
describe('Monthly last day recurrence', () => {
it('should calculate last day of month correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year last day correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2024-01-15T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of February 2024 is February 29th
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle different month lengths', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2025-04-15T10:00:00Z'); // April has 30 days
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of May 2025 is May 31st
expect(nextDate).toEqual(new Date('2025-05-31T10:00:00Z'));
});
});
// Test edge cases and invalid inputs
describe('Edge cases and invalid inputs', () => {
it('should return null for unsupported recurrence type', () => {
const task = {
recurrence_type: 'invalid_type',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should return null for none recurrence type', () => {
const task = {
recurrence_type: 'none',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should handle invalid date inputs gracefully', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1,
};
const fromDate = new Date('invalid-date');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should handle missing task properties', () => {
const task = {}; // No recurrence properties
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
});
});
it('should handle custom daily intervals', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 3
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-18T10:00:00Z'));
});
describe('Helper Functions', () => {
describe('_getFirstWeekdayOfMonth', () => {
it('should find first Monday of January 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(
2025,
0,
1
); // January, Monday
expect(date.getDate()).toBe(6); // January 6, 2025 is the first Monday
});
it('should handle edge case with zero interval', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 0
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
});
});
it('should find first Sunday of February 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(
2025,
1,
0
); // February, Sunday
expect(date.getDate()).toBe(2); // February 2, 2025 is the first Sunday
});
});
// Test weekly recurrence
describe('Weekly recurrence', () => {
it('should calculate next weekly occurrence correctly', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-22T10:00:00Z'));
});
describe('_getLastWeekdayOfMonth', () => {
it('should find last Friday of January 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(
2025,
0,
5
); // January, Friday
expect(date.getDate()).toBe(31); // January 31, 2025 is the last Friday
});
it('should handle weekly with specific weekday', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: 1 // Monday
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-20T10:00:00Z')); // Next Monday
});
it('should find last Monday of February 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(
2025,
1,
1
); // February, Monday
expect(date.getDate()).toBe(24); // February 24, 2025 is the last Monday
});
});
it('should handle bi-weekly recurrence', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 2
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-01-29T10:00:00Z'));
});
});
describe('_getNthWeekdayOfMonth', () => {
it('should find second Tuesday of March 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(
2025,
2,
2,
2
); // March, Tuesday, 2nd
expect(date.getDate()).toBe(11); // March 11, 2025 is the second Tuesday
});
// Test monthly recurrence
describe('Monthly recurrence', () => {
it('should calculate next monthly occurrence correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-02-15T10:00:00Z'));
it('should find fourth Thursday of April 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(
2025,
3,
4,
4
); // April, Thursday, 4th
expect(date.getDate()).toBe(24); // April 24, 2025 is the fourth Thursday
});
});
});
it('should handle month boundaries correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-31T10:00:00Z'); // January 31st
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// February only has 28 days in 2025, should go to Feb 28
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1
};
const fromDate = new Date('2024-01-29T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle custom monthly intervals', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 3
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-04-15T10:00:00Z'));
});
it('should handle monthly with specific day', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: 5
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toEqual(new Date('2025-02-05T10:00:00Z'));
});
});
// Test monthly weekday recurrence
describe('Monthly weekday recurrence', () => {
it('should calculate first Monday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
recurrence_week_of_month: 1 // First week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// First Monday of February 2025 is February 3rd
expect(nextDate).toEqual(new Date('2025-02-03T10:00:00Z'));
});
it('should calculate last Friday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 5, // Friday
recurrence_week_of_month: 5 // Last week (represented as 5)
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// Last Friday of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle third Wednesday of month', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 3, // Wednesday
recurrence_week_of_month: 3 // Third week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// Third Wednesday of February 2025 is February 19th
expect(nextDate).toEqual(new Date('2025-02-19T10:00:00Z'));
});
});
// Test monthly last day recurrence
describe('Monthly last day recurrence', () => {
it('should calculate last day of month correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// Last day of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year last day correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1
};
const fromDate = new Date('2024-01-15T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// Last day of February 2024 is February 29th
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle different month lengths', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1
};
const fromDate = new Date('2025-04-15T10:00:00Z'); // April has 30 days
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
// Last day of May 2025 is May 31st
expect(nextDate).toEqual(new Date('2025-05-31T10:00:00Z'));
});
});
// Test edge cases and invalid inputs
describe('Edge cases and invalid inputs', () => {
it('should return null for unsupported recurrence type', () => {
const task = {
recurrence_type: 'invalid_type',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toBeNull();
});
it('should return null for none recurrence type', () => {
const task = {
recurrence_type: 'none',
recurrence_interval: 1
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toBeNull();
});
it('should handle invalid date inputs gracefully', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1
};
const fromDate = new Date('invalid-date');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toBeNull();
});
it('should handle missing task properties', () => {
const task = {}; // No recurrence properties
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(task, fromDate);
expect(nextDate).toBeNull();
});
});
});
describe('Helper Functions', () => {
describe('_getFirstWeekdayOfMonth', () => {
it('should find first Monday of January 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(2025, 0, 1); // January, Monday
expect(date.getDate()).toBe(6); // January 6, 2025 is the first Monday
describe('Task Generation Tests', () => {
describe('createTaskInstance', () => {
it('should create a task instance with correct parent relationship', async () => {
const template = {
id: 1,
name: 'Test Recurring Task',
description: 'Test description',
priority: 1,
note: 'Test note',
user_id: 1,
project_id: 2,
};
const dueDate = new Date('2025-01-20T10:00:00Z');
// Mock Task.create
const mockCreate = jest.fn().mockResolvedValue({
id: 10,
name: template.name,
description: template.description,
due_date: dueDate,
priority: template.priority,
status: 0, // NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id,
});
Task.create = mockCreate;
const result = await RecurringTaskService.createTaskInstance(
template,
dueDate
);
expect(mockCreate).toHaveBeenCalledWith({
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: 0, // Task.STATUS.NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id,
});
expect(result.recurring_parent_id).toBe(template.id);
expect(result.recurrence_type).toBe('none');
});
});
it('should find first Sunday of February 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(2025, 1, 0); // February, Sunday
expect(date.getDate()).toBe(2); // February 2, 2025 is the first Sunday
});
});
describe('_getLastWeekdayOfMonth', () => {
it('should find last Friday of January 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(2025, 0, 5); // January, Friday
expect(date.getDate()).toBe(31); // January 31, 2025 is the last Friday
});
it('should find last Monday of February 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(2025, 1, 1); // February, Monday
expect(date.getDate()).toBe(24); // February 24, 2025 is the last Monday
});
});
describe('_getNthWeekdayOfMonth', () => {
it('should find second Tuesday of March 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(2025, 2, 2, 2); // March, Tuesday, 2nd
expect(date.getDate()).toBe(11); // March 11, 2025 is the second Tuesday
});
it('should find fourth Thursday of April 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(2025, 3, 4, 4); // April, Thursday, 4th
expect(date.getDate()).toBe(24); // April 24, 2025 is the fourth Thursday
});
});
});
});
describe('Task Generation Tests', () => {
describe('createTaskInstance', () => {
it('should create a task instance with correct parent relationship', async () => {
const template = {
id: 1,
name: 'Test Recurring Task',
description: 'Test description',
priority: 1,
note: 'Test note',
user_id: 1,
project_id: 2
};
const dueDate = new Date('2025-01-20T10:00:00Z');
// Mock Task.create
const mockCreate = jest.fn().mockResolvedValue({
id: 10,
name: template.name,
description: template.description,
due_date: dueDate,
priority: template.priority,
status: 0, // NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id
});
Task.create = mockCreate;
const result = await RecurringTaskService.createTaskInstance(template, dueDate);
expect(mockCreate).toHaveBeenCalledWith({
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: 0, // Task.STATUS.NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id
});
expect(result.recurring_parent_id).toBe(template.id);
expect(result.recurrence_type).toBe('none');
});
});
});
describe('End Date Validation', () => {
describe('shouldGenerateNextTask', () => {
it('should generate task when no end date is set', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: null
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
expect(shouldGenerate).toBe(true);
});
it('should generate task when next date is before end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-12-31T10:00:00Z')
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
expect(shouldGenerate).toBe(true);
});
it('should not generate task when next date is after end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-06-15T10:00:00Z')
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
expect(shouldGenerate).toBe(false);
});
it('should not generate task when next date equals end date', () => {
const endDate = new Date('2025-06-15T10:00:00Z');
const task = {
recurrence_type: 'daily',
recurrence_end_date: endDate
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate = RecurringTaskService._shouldGenerateNextTask(task, nextDate);
expect(shouldGenerate).toBe(false);
});
});
});
describe('Service Interface', () => {
it('should export all required methods', () => {
expect(typeof RecurringTaskService.generateRecurringTasks).toBe('function');
expect(typeof RecurringTaskService.processRecurringTask).toBe('function');
expect(typeof RecurringTaskService.calculateNextDueDate).toBe('function');
expect(typeof RecurringTaskService.createTaskInstance).toBe('function');
expect(typeof RecurringTaskService.handleTaskCompletion).toBe('function');
});
it('should have helper functions for testing', () => {
expect(typeof RecurringTaskService._getFirstWeekdayOfMonth).toBe('function');
expect(typeof RecurringTaskService._getLastWeekdayOfMonth).toBe('function');
expect(typeof RecurringTaskService._getNthWeekdayOfMonth).toBe('function');
expect(typeof RecurringTaskService._shouldGenerateNextTask).toBe('function');
describe('End Date Validation', () => {
describe('shouldGenerateNextTask', () => {
it('should generate task when no end date is set', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: null,
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(true);
});
it('should generate task when next date is before end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-12-31T10:00:00Z'),
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(true);
});
it('should not generate task when next date is after end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-06-15T10:00:00Z'),
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(false);
});
it('should not generate task when next date equals end date', () => {
const endDate = new Date('2025-06-15T10:00:00Z');
const task = {
recurrence_type: 'daily',
recurrence_end_date: endDate,
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(false);
});
});
});
});
});
describe('Service Interface', () => {
it('should export all required methods', () => {
expect(typeof RecurringTaskService.generateRecurringTasks).toBe(
'function'
);
expect(typeof RecurringTaskService.processRecurringTask).toBe(
'function'
);
expect(typeof RecurringTaskService.calculateNextDueDate).toBe(
'function'
);
expect(typeof RecurringTaskService.createTaskInstance).toBe(
'function'
);
expect(typeof RecurringTaskService.handleTaskCompletion).toBe(
'function'
);
});
it('should have helper functions for testing', () => {
expect(typeof RecurringTaskService._getFirstWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._getLastWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._getNthWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._shouldGenerateNextTask).toBe(
'function'
);
});
});
});

View file

@ -3,170 +3,204 @@ const telegramPoller = require('../../../services/telegramPoller');
// Mock the database models
jest.mock('../../../models', () => ({
User: {
update: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn()
},
InboxItem: {
create: jest.fn(),
findOne: jest.fn()
}
User: {
update: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
},
InboxItem: {
create: jest.fn(),
findOne: jest.fn(),
},
}));
// Mock https module
jest.mock('https', () => ({
get: jest.fn(),
request: jest.fn()
get: jest.fn(),
request: jest.fn(),
}));
describe('TelegramPoller Duplicate Prevention', () => {
let mockUser;
beforeEach(() => {
jest.clearAllMocks();
mockUser = {
id: 1,
telegram_bot_token: 'test-token',
telegram_chat_id: '123456789'
};
// Reset poller state
telegramPoller.stopPolling();
});
let mockUser;
describe('Update ID Tracking', () => {
test('should filter out already processed updates', () => {
const updates = [
{ update_id: 100, message: { text: 'Hello 1', message_id: 1, chat: { id: 123 } } },
{ update_id: 101, message: { text: 'Hello 2', message_id: 2, chat: { id: 123 } } },
{ update_id: 102, message: { text: 'Hello 3', message_id: 3, chat: { id: 123 } } }
];
beforeEach(() => {
jest.clearAllMocks();
// Test internal function for filtering
const processedUpdates = new Set(['1-100', '1-101']);
const newUpdates = updates.filter(update => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
mockUser = {
id: 1,
telegram_bot_token: 'test-token',
telegram_chat_id: '123456789',
};
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(102);
// Reset poller state
telegramPoller.stopPolling();
});
test('should track highest update ID correctly', () => {
const updates = [
{ update_id: 98 },
{ update_id: 101 },
{ update_id: 99 }
];
describe('Update ID Tracking', () => {
test('should filter out already processed updates', () => {
const updates = [
{
update_id: 100,
message: {
text: 'Hello 1',
message_id: 1,
chat: { id: 123 },
},
},
{
update_id: 101,
message: {
text: 'Hello 2',
message_id: 2,
chat: { id: 123 },
},
},
{
update_id: 102,
message: {
text: 'Hello 3',
message_id: 3,
chat: { id: 123 },
},
},
];
const highestUpdateId = telegramPoller._getHighestUpdateId(updates);
expect(highestUpdateId).toBe(101);
// Test internal function for filtering
const processedUpdates = new Set(['1-100', '1-101']);
const newUpdates = updates.filter((update) => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(102);
});
test('should track highest update ID correctly', () => {
const updates = [
{ update_id: 98 },
{ update_id: 101 },
{ update_id: 99 },
];
const highestUpdateId = telegramPoller._getHighestUpdateId(updates);
expect(highestUpdateId).toBe(101);
});
test('should handle empty updates array', () => {
const highestUpdateId = telegramPoller._getHighestUpdateId([]);
expect(highestUpdateId).toBe(0);
});
});
test('should handle empty updates array', () => {
const highestUpdateId = telegramPoller._getHighestUpdateId([]);
expect(highestUpdateId).toBe(0);
});
});
describe('User List Management', () => {
test('should not add duplicate users', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 1, name: 'User 1 Updated' };
describe('User List Management', () => {
test('should not add duplicate users', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 1, name: 'User 1 Updated' };
const userExists = telegramPoller._userExistsInList(users, 1);
expect(userExists).toBe(true);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(1);
expect(updatedUsers).toEqual(users); // Should return original array unchanged
const userExists = telegramPoller._userExistsInList(users, 1);
expect(userExists).toBe(true);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(1);
expect(updatedUsers).toEqual(users); // Should return original array unchanged
});
test('should add new users correctly', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 2, name: 'User 2' };
const userExists = telegramPoller._userExistsInList(users, 2);
expect(userExists).toBe(false);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers).toContain(newUser);
});
test('should remove users correctly', () => {
const users = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' },
];
const updatedUsers = telegramPoller._removeUserFromList(users, 2);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers.find((u) => u.id === 2)).toBeUndefined();
expect(updatedUsers.find((u) => u.id === 1)).toBeDefined();
expect(updatedUsers.find((u) => u.id === 3)).toBeDefined();
});
});
test('should add new users correctly', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 2, name: 'User 2' };
const userExists = telegramPoller._userExistsInList(users, 2);
expect(userExists).toBe(false);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers).toContain(newUser);
describe('Message Parameters', () => {
test('should create message parameters without reply', () => {
const params = telegramPoller._createMessageParams(
'123',
'Hello World'
);
expect(params).toEqual({
chat_id: '123',
text: 'Hello World',
});
});
test('should create message parameters with reply', () => {
const params = telegramPoller._createMessageParams(
'123',
'Hello World',
456
);
expect(params).toEqual({
chat_id: '123',
text: 'Hello World',
reply_to_message_id: 456,
});
});
});
test('should remove users correctly', () => {
const users = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
];
const updatedUsers = telegramPoller._removeUserFromList(users, 2);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers.find(u => u.id === 2)).toBeUndefined();
expect(updatedUsers.find(u => u.id === 1)).toBeDefined();
expect(updatedUsers.find(u => u.id === 3)).toBeDefined();
});
});
describe('Telegram URL Creation', () => {
test('should create URL without parameters', () => {
const url = telegramPoller._createTelegramUrl('token123', 'getMe');
expect(url).toBe('https://api.telegram.org/bottoken123/getMe');
});
describe('Message Parameters', () => {
test('should create message parameters without reply', () => {
const params = telegramPoller._createMessageParams('123', 'Hello World');
expect(params).toEqual({
chat_id: '123',
text: 'Hello World'
});
test('should create URL with parameters', () => {
const url = telegramPoller._createTelegramUrl(
'token123',
'getUpdates',
{
offset: '100',
timeout: '30',
}
);
expect(url).toBe(
'https://api.telegram.org/bottoken123/getUpdates?offset=100&timeout=30'
);
});
});
test('should create message parameters with reply', () => {
const params = telegramPoller._createMessageParams('123', 'Hello World', 456);
expect(params).toEqual({
chat_id: '123',
text: 'Hello World',
reply_to_message_id: 456
});
});
});
describe('State Management', () => {
test('should return correct initial state', () => {
const state = telegramPoller._createPollerState();
expect(state).toEqual({
running: false,
interval: null,
pollInterval: 5000,
usersToPool: [],
userStatus: {},
processedUpdates: expect.any(Set),
});
});
describe('Telegram URL Creation', () => {
test('should create URL without parameters', () => {
const url = telegramPoller._createTelegramUrl('token123', 'getMe');
expect(url).toBe('https://api.telegram.org/bottoken123/getMe');
test('should track poller status correctly', () => {
const status = telegramPoller.getStatus();
expect(status).toEqual({
running: false,
usersCount: 0,
pollInterval: 5000,
userStatus: {},
});
});
});
test('should create URL with parameters', () => {
const url = telegramPoller._createTelegramUrl('token123', 'getUpdates', {
offset: '100',
timeout: '30'
});
expect(url).toBe('https://api.telegram.org/bottoken123/getUpdates?offset=100&timeout=30');
});
});
describe('State Management', () => {
test('should return correct initial state', () => {
const state = telegramPoller._createPollerState();
expect(state).toEqual({
running: false,
interval: null,
pollInterval: 5000,
usersToPool: [],
userStatus: {},
processedUpdates: expect.any(Set)
});
});
test('should track poller status correctly', () => {
const status = telegramPoller.getStatus();
expect(status).toEqual({
running: false,
usersCount: 0,
pollInterval: 5000,
userStatus: {}
});
});
});
});
});

17090
frontend/package-lock.json generated

File diff suppressed because it is too large Load diff