diff --git a/backend/app.js b/backend/app.js index 127a97e..8b5cf3d 100644 --- a/backend/app.js +++ b/backend/app.js @@ -8,8 +8,10 @@ const morgan = require('morgan'); const session = require('express-session'); const SequelizeStore = require('connect-session-sequelize')(session.Store); const { sequelize } = require('./models'); -const { initializeTelegramPolling } = require('./services/telegramInitializer'); -const taskScheduler = require('./services/taskScheduler'); +const { + initializeTelegramPolling, +} = require('./modules/telegram/telegramInitializer'); +const taskScheduler = require('./modules/tasks/taskScheduler'); const { setConfig, getConfig } = require('./config/config'); const config = getConfig(); const API_VERSION = process.env.API_VERSION || 'v1'; @@ -107,6 +109,30 @@ const { authenticatedApiLimiter, } = require('./middleware/rateLimiter'); +// Error handler for modular architecture +const errorHandler = require('./shared/middleware/errorHandler'); + +// Modular routes +const adminModule = require('./modules/admin'); +const areasModule = require('./modules/areas'); +const authModule = require('./modules/auth'); +const backupModule = require('./modules/backup'); +const featureFlagsModule = require('./modules/feature-flags'); +const habitsModule = require('./modules/habits'); +const inboxModule = require('./modules/inbox'); +const notesModule = require('./modules/notes'); +const notificationsModule = require('./modules/notifications'); +const projectsModule = require('./modules/projects'); +const quotesModule = require('./modules/quotes'); +const searchModule = require('./modules/search'); +const sharesModule = require('./modules/shares'); +const tagsModule = require('./modules/tags'); +const tasksModule = require('./modules/tasks'); +const telegramModule = require('./modules/telegram'); +const urlModule = require('./modules/url'); +const usersModule = require('./modules/users'); +const viewsModule = require('./modules/views'); + // Swagger documentation - enabled by default, protected by authentication // Mounted on /api-docs to avoid conflicts with API routes if (config.swagger.enabled) { @@ -164,29 +190,28 @@ if (API_VERSION && API_BASE_PATH !== '/api') { } healthPaths.forEach(registerHealthCheck); -// Routes const registerApiRoutes = (basePath) => { - app.use(basePath, require('./routes/auth')); - app.use(basePath, require('./routes/feature-flags')); + app.use(basePath, authModule.routes); + app.use(basePath, featureFlagsModule.routes); app.use(basePath, requireAuth); - app.use(basePath, require('./routes/tasks')); - app.use(`${basePath}/habits`, require('./routes/habits')); - app.use(basePath, require('./routes/projects')); - app.use(basePath, require('./routes/admin')); - app.use(basePath, require('./routes/shares')); - app.use(basePath, require('./routes/areas')); - app.use(basePath, require('./routes/notes')); - app.use(basePath, require('./routes/tags')); - app.use(basePath, require('./routes/users')); - app.use(basePath, require('./routes/inbox')); - app.use(basePath, require('./routes/url')); - app.use(basePath, require('./routes/telegram')); - app.use(basePath, require('./routes/quotes')); - app.use(`${basePath}/backup`, require('./routes/backup')); - app.use(`${basePath}/search`, require('./routes/search')); - app.use(`${basePath}/views`, require('./routes/views')); - app.use(`${basePath}/notifications`, require('./routes/notifications')); + app.use(basePath, tasksModule.routes); + app.use(basePath, habitsModule.routes); + app.use(basePath, projectsModule.routes); + app.use(basePath, adminModule.routes); + app.use(basePath, sharesModule.routes); + app.use(basePath, areasModule.routes); + app.use(basePath, notesModule.routes); + app.use(basePath, tagsModule.routes); + app.use(basePath, usersModule.routes); + app.use(basePath, inboxModule.routes); + app.use(basePath, urlModule.routes); + app.use(basePath, telegramModule.routes); + app.use(basePath, quotesModule.routes); + app.use(basePath, backupModule.routes); + app.use(basePath, searchModule.routes); + app.use(basePath, viewsModule.routes); + app.use(basePath, notificationsModule.routes); }; // Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility @@ -216,17 +241,8 @@ app.get('*', (req, res) => { } }); -// Error handling fallback. -// We shouldn't be here normally! -// Each route should properly handle -// and log its own errors. -app.use((err, req, res, next) => { - logError(err); - res.status(500).json({ - error: 'Internal Server Error', - // message: err.message, - }); -}); +// Error handling middleware (handles AppError and Sequelize errors) +app.use(errorHandler); // Initialize database and start server async function startServer() { diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 92e960b..85de1ec 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,5 +1,5 @@ const { User } = require('../models'); -const { findValidTokenByValue } = require('../services/apiTokenService'); +const { findValidTokenByValue } = require('../modules/users/apiTokenService'); const getBearerToken = (req) => { const authHeader = req.headers?.authorization || ''; diff --git a/backend/migrations/20251229000001-add-task-performance-indexes.js b/backend/migrations/20251229000001-add-task-performance-indexes.js new file mode 100644 index 0000000..84f69da --- /dev/null +++ b/backend/migrations/20251229000001-add-task-performance-indexes.js @@ -0,0 +1,93 @@ +'use strict'; + +const { safeAddIndex } = require('../utils/migration-utils'); + +/** + * Migration to add performance indexes to the tasks table. + * These indexes improve query performance on slow I/O systems (e.g., Synology NAS with HDDs). + * + * Missing indexes identified from query analysis: + * - status: Used in almost every task query + * - due_date: Used in today/upcoming/overdue queries + * - recurring_parent_id: Used in recurring task filtering + * - completed_at: Used in completion queries + * - Composite indexes for common query patterns + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Single column indexes for frequently filtered columns + await safeAddIndex(queryInterface, 'tasks', ['status'], { + name: 'tasks_status_idx', + }); + + await safeAddIndex(queryInterface, 'tasks', ['due_date'], { + name: 'tasks_due_date_idx', + }); + + await safeAddIndex(queryInterface, 'tasks', ['recurring_parent_id'], { + name: 'tasks_recurring_parent_id_idx', + }); + + await safeAddIndex(queryInterface, 'tasks', ['completed_at'], { + name: 'tasks_completed_at_idx', + }); + + // Composite indexes for common query patterns + await safeAddIndex(queryInterface, 'tasks', ['user_id', 'status'], { + name: 'tasks_user_id_status_idx', + }); + + await safeAddIndex( + queryInterface, + 'tasks', + ['user_id', 'status', 'parent_task_id'], + { + name: 'tasks_user_status_parent_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'tasks', + ['user_id', 'due_date', 'status'], + { + name: 'tasks_user_due_date_status_idx', + } + ); + + await safeAddIndex( + queryInterface, + 'tasks', + ['user_id', 'completed_at', 'status'], + { + name: 'tasks_user_completed_at_status_idx', + } + ); + }, + + async down(queryInterface, Sequelize) { + // Remove indexes in reverse order + const indexNames = [ + 'tasks_user_completed_at_status_idx', + 'tasks_user_due_date_status_idx', + 'tasks_user_status_parent_idx', + 'tasks_user_id_status_idx', + 'tasks_completed_at_idx', + 'tasks_recurring_parent_id_idx', + 'tasks_due_date_idx', + 'tasks_status_idx', + ]; + + for (const indexName of indexNames) { + try { + await queryInterface.removeIndex('tasks', indexName); + } catch (error) { + console.log( + `Index ${indexName} may not exist, skipping removal` + ); + } + } + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index d42d7a6..0b04398 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -19,6 +19,37 @@ dbConfig = { const sequelize = new Sequelize(dbConfig); +// SQLite performance optimizations for slow I/O systems (e.g., Synology NAS with HDDs) +if (dbConfig.dialect === 'sqlite') { + const pragmas = [ + // WAL mode: sequential writes instead of random I/O, better for Btrfs COW + 'PRAGMA journal_mode=WAL;', + // Relaxed sync: faster writes with minimal durability risk for single-user app + 'PRAGMA synchronous=NORMAL;', + // 5 second busy timeout: prevents "database is locked" errors under load + 'PRAGMA busy_timeout=5000;', + // 64MB cache: keeps more data in memory, reduces disk reads + 'PRAGMA cache_size=-64000;', + // Store temp tables in memory instead of disk + 'PRAGMA temp_store=MEMORY;', + // Enable memory-mapped I/O (256MB): faster reads on large databases + 'PRAGMA mmap_size=268435456;', + ]; + + (async () => { + try { + for (const pragma of pragmas) { + await sequelize.query(pragma); + } + if (config.environment === 'development') { + console.log('SQLite performance optimizations enabled'); + } + } catch (err) { + console.error('Failed to set SQLite PRAGMAs:', err.message); + } + })(); +} + const User = require('./user')(sequelize); const Area = require('./area')(sequelize); const Project = require('./project')(sequelize); diff --git a/backend/models/notification.js b/backend/models/notification.js index 1f52648..c5a2970 100644 --- a/backend/models/notification.js +++ b/backend/models/notification.js @@ -212,7 +212,7 @@ module.exports = (sequelize) => { NotificationModel ) { try { - const telegramService = require('../services/telegramNotificationService'); + const telegramService = require('../modules/telegram/telegramNotificationService'); if (!message) { return; diff --git a/backend/models/task.js b/backend/models/task.js index 36c433b..d13b394 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -209,6 +209,39 @@ module.exports = (sequelize) => { name: 'tasks_parent_task_id_order', fields: ['parent_task_id', 'order'], }, + // Performance indexes for slow I/O systems (e.g., Synology NAS) + { + name: 'tasks_status_idx', + fields: ['status'], + }, + { + name: 'tasks_due_date_idx', + fields: ['due_date'], + }, + { + name: 'tasks_recurring_parent_id_idx', + fields: ['recurring_parent_id'], + }, + { + name: 'tasks_completed_at_idx', + fields: ['completed_at'], + }, + { + name: 'tasks_user_id_status_idx', + fields: ['user_id', 'status'], + }, + { + name: 'tasks_user_status_parent_idx', + fields: ['user_id', 'status', 'parent_task_id'], + }, + { + name: 'tasks_user_due_date_status_idx', + fields: ['user_id', 'due_date', 'status'], + }, + { + name: 'tasks_user_completed_at_status_idx', + fields: ['user_id', 'completed_at', 'status'], + }, ], } ); diff --git a/backend/modules/admin/controller.js b/backend/modules/admin/controller.js new file mode 100644 index 0000000..08ac998 --- /dev/null +++ b/backend/modules/admin/controller.js @@ -0,0 +1,111 @@ +'use strict'; + +const adminService = require('./service'); + +/** + * Get requester ID from request. + */ +function getRequesterId(req) { + return req.currentUser?.id || req.session?.userId; +} + +/** + * Admin controller - handles HTTP requests/responses. + */ +const adminController = { + /** + * POST /api/admin/set-admin-role + * Set admin role for a user. + */ + async setAdminRole(req, res, next) { + try { + const requesterId = getRequesterId(req); + const result = await adminService.setAdminRole( + requesterId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/admin/users + * List all users with roles. + */ + async listUsers(req, res, next) { + try { + const requesterId = getRequesterId(req); + const users = await adminService.listUsers(requesterId); + res.json(users); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/admin/users + * Create a new user. + */ + async createUser(req, res, next) { + try { + const requesterId = getRequesterId(req); + const user = await adminService.createUser(requesterId, req.body); + res.status(201).json(user); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/admin/users/:id + * Update a user. + */ + async updateUser(req, res, next) { + try { + const requesterId = getRequesterId(req); + const user = await adminService.updateUser( + requesterId, + req.params.id, + req.body + ); + res.json(user); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/admin/users/:id + * Delete a user. + */ + async deleteUser(req, res, next) { + try { + const requesterId = getRequesterId(req); + await adminService.deleteUser(requesterId, req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/admin/toggle-registration + * Toggle registration setting. + */ + async toggleRegistration(req, res, next) { + try { + const requesterId = getRequesterId(req); + const result = await adminService.toggleRegistration( + requesterId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = adminController; diff --git a/backend/modules/admin/index.js b/backend/modules/admin/index.js new file mode 100644 index 0000000..5dc940c --- /dev/null +++ b/backend/modules/admin/index.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * Admin Module + * + * This module handles all admin-related functionality including: + * - User management (CRUD operations) + * - Role management (admin/user) + * - Registration toggle + * + * Usage: + * const adminModule = require('./modules/admin'); + * app.use('/api', adminModule.routes); + */ + +const routes = require('./routes'); +const adminService = require('./service'); +const adminRepository = require('./repository'); +const { + validateUserId, + validateEmail, + validatePassword, + validateSetAdminRole, + validateCreateUser, + validateToggleRegistration, +} = require('./validation'); + +module.exports = { + routes, + adminService, + adminRepository, + validateUserId, + validateEmail, + validatePassword, + validateSetAdminRole, + validateCreateUser, + validateToggleRegistration, +}; diff --git a/backend/modules/admin/repository.js b/backend/modules/admin/repository.js new file mode 100644 index 0000000..c198b8a --- /dev/null +++ b/backend/modules/admin/repository.js @@ -0,0 +1,188 @@ +'use strict'; + +const { + User, + Role, + Area, + Project, + Task, + Tag, + Note, + InboxItem, + TaskEvent, + Action, + Permission, + View, + ApiToken, + Notification, + RecurringCompletion, + sequelize, +} = require('../../models'); + +class AdminRepository { + /** + * Find all users with basic attributes. + */ + async findAllUsers() { + return User.findAll({ + attributes: ['id', 'email', 'name', 'surname', 'created_at'], + }); + } + + /** + * Find all roles. + */ + async findAllRoles() { + return Role.findAll({ + attributes: ['user_id', 'is_admin'], + }); + } + + /** + * Find user by ID. + */ + async findUserById(id, options = {}) { + return User.findByPk(id, options); + } + + /** + * Find user by ID with UID attribute only. + */ + async findUserUidById(id) { + return User.findByPk(id, { attributes: ['uid'] }); + } + + /** + * Create a user. + */ + async createUser(userData) { + return User.create(userData); + } + + /** + * Find or create a role. + */ + async findOrCreateRole(userId, isAdmin) { + return Role.findOrCreate({ + where: { user_id: userId }, + defaults: { user_id: userId, is_admin: isAdmin }, + }); + } + + /** + * Find role by user ID. + */ + async findRoleByUserId(userId, options = {}) { + return Role.findOne({ where: { user_id: userId }, ...options }); + } + + /** + * Count roles. + */ + async countRoles() { + return Role.count(); + } + + /** + * Count admin roles. + */ + async countAdminRoles(options = {}) { + return Role.count({ where: { is_admin: true }, ...options }); + } + + /** + * Delete a user and all associated data in a transaction. + */ + async deleteUserWithData(userId, requesterId) { + const transaction = await sequelize.transaction(); + + try { + const user = await User.findByPk(userId, { transaction }); + if (!user) { + await transaction.rollback(); + return { success: false, error: 'User not found', status: 404 }; + } + + // Prevent deleting the last remaining admin + const targetRole = await Role.findOne({ + where: { user_id: userId }, + transaction, + }); + if (targetRole?.is_admin) { + const adminCount = await Role.count({ + where: { is_admin: true }, + transaction, + }); + if (adminCount <= 1) { + await transaction.rollback(); + return { + success: false, + error: 'Cannot delete the last remaining admin', + status: 400, + }; + } + } + + // Delete all associated data + await TaskEvent.destroy({ + where: { user_id: userId }, + transaction, + }); + + const userTasks = await Task.findAll({ + where: { user_id: userId }, + attributes: ['id'], + transaction, + }); + const taskIds = userTasks.map((t) => t.id); + if (taskIds.length > 0) { + await RecurringCompletion.destroy({ + where: { task_id: taskIds }, + transaction, + }); + } + + await Task.destroy({ where: { user_id: userId }, transaction }); + await Note.destroy({ where: { user_id: userId }, transaction }); + await Project.destroy({ where: { user_id: userId }, transaction }); + await Area.destroy({ where: { user_id: userId }, transaction }); + await Tag.destroy({ where: { user_id: userId }, transaction }); + await InboxItem.destroy({ + where: { user_id: userId }, + transaction, + }); + await View.destroy({ where: { user_id: userId }, transaction }); + await Notification.destroy({ + where: { user_id: userId }, + transaction, + }); + await ApiToken.destroy({ where: { user_id: userId }, transaction }); + await Permission.destroy({ + where: { user_id: userId }, + transaction, + }); + await Permission.destroy({ + where: { granted_by_user_id: userId }, + transaction, + }); + await Action.destroy({ + where: { actor_user_id: userId }, + transaction, + }); + await Action.destroy({ + where: { target_user_id: userId }, + transaction, + }); + await Role.destroy({ where: { user_id: userId }, transaction }); + await user.destroy({ transaction }); + + await transaction.commit(); + return { success: true }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +module.exports = new AdminRepository(); diff --git a/backend/modules/admin/routes.js b/backend/modules/admin/routes.js new file mode 100644 index 0000000..5fb8b90 --- /dev/null +++ b/backend/modules/admin/routes.js @@ -0,0 +1,17 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const adminController = require('./controller'); + +// All routes require authentication (handled by app.js middleware) +// Admin verification is done in the service layer + +router.post('/admin/set-admin-role', adminController.setAdminRole); +router.get('/admin/users', adminController.listUsers); +router.post('/admin/users', adminController.createUser); +router.put('/admin/users/:id', adminController.updateUser); +router.delete('/admin/users/:id', adminController.deleteUser); +router.post('/admin/toggle-registration', adminController.toggleRegistration); + +module.exports = router; diff --git a/backend/modules/admin/service.js b/backend/modules/admin/service.js new file mode 100644 index 0000000..a7237fe --- /dev/null +++ b/backend/modules/admin/service.js @@ -0,0 +1,258 @@ +'use strict'; + +const adminRepository = require('./repository'); +const { + validateUserId, + validateEmail, + validatePassword, + validateSetAdminRole, + validateCreateUser, + validateToggleRegistration, +} = require('./validation'); +const { + NotFoundError, + ValidationError, + ForbiddenError, + UnauthorizedError, + ConflictError, +} = require('../../shared/errors'); +const { isAdmin } = require('../../services/rolesService'); + +class AdminService { + /** + * Check if requester is admin or if bootstrapping (no roles yet). + */ + async verifyAdminOrBootstrap(requesterId) { + if (!requesterId) { + throw new UnauthorizedError('Authentication required'); + } + + const requester = await adminRepository.findUserUidById(requesterId); + if (!requester) { + throw new UnauthorizedError('Authentication required'); + } + + const requesterIsAdmin = await isAdmin(requester.uid); + const existingRolesCount = await adminRepository.countRoles(); + + if (!requesterIsAdmin && existingRolesCount > 0) { + throw new ForbiddenError('Forbidden'); + } + + return true; + } + + /** + * Check if requester is admin. + */ + async verifyAdmin(requesterId) { + if (!requesterId) { + throw new UnauthorizedError('Authentication required'); + } + + const user = await adminRepository.findUserUidById(requesterId); + if (!user) { + throw new UnauthorizedError('Authentication required'); + } + + const admin = await isAdmin(user.uid); + if (!admin) { + throw new ForbiddenError('Forbidden'); + } + + return true; + } + + /** + * Set admin role for a user. + */ + async setAdminRole(requesterId, body) { + await this.verifyAdminOrBootstrap(requesterId); + + const { user_id, is_admin: makeAdmin } = validateSetAdminRole(body); + + const user = await adminRepository.findUserById(user_id); + if (!user) { + throw new ValidationError('Invalid user_id'); + } + + const [role] = await adminRepository.findOrCreateRole( + user_id, + makeAdmin + ); + if (role.is_admin !== makeAdmin) { + role.is_admin = makeAdmin; + await role.save(); + } + + return { user_id, is_admin: role.is_admin }; + } + + /** + * List all users with roles. + */ + async listUsers(requesterId) { + await this.verifyAdmin(requesterId); + + const users = await adminRepository.findAllUsers(); + const roles = await adminRepository.findAllRoles(); + const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin])); + + return users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + surname: u.surname, + created_at: u.created_at, + role: userIdToRole.get(u.id) ? 'admin' : 'user', + })); + } + + /** + * Create a new user. + */ + async createUser(requesterId, body) { + await this.verifyAdmin(requesterId); + + const { email, password, name, surname, role } = + validateCreateUser(body); + + const userData = { email, password }; + if (name) userData.name = name; + if (surname) userData.surname = surname; + + let user; + try { + user = await adminRepository.createUser(userData); + } catch (err) { + if (err?.name === 'SequelizeUniqueConstraintError') { + throw new ConflictError('Email already exists'); + } + throw err; + } + + const makeAdmin = role === 'admin'; + if (makeAdmin) { + const [userRole, roleCreated] = + await adminRepository.findOrCreateRole(user.id, true); + if (!roleCreated && !userRole.is_admin) { + userRole.is_admin = true; + await userRole.save(); + } + } + + return { + id: user.id, + email: user.email, + name: user.name, + surname: user.surname, + created_at: user.created_at, + role: makeAdmin ? 'admin' : 'user', + }; + } + + /** + * Update a user. + */ + async updateUser(requesterId, userId, body) { + await this.verifyAdmin(requesterId); + + const id = validateUserId(userId); + const user = await adminRepository.findUserById(id); + if (!user) { + throw new NotFoundError('User not found'); + } + + const { email, password, name, surname, role } = body || {}; + + if (email !== undefined && email !== null) { + validateEmail(email); + user.email = email; + } + + if (password && password.trim() !== '') { + validatePassword(password); + user.password = password; + } + + if (name !== undefined) user.name = name || null; + if (surname !== undefined) user.surname = surname || null; + + try { + await user.save(); + } catch (err) { + if (err?.name === 'SequelizeUniqueConstraintError') { + throw new ConflictError('Email already exists'); + } + throw err; + } + + if (role !== undefined) { + const makeAdmin = role === 'admin'; + const [userRole] = await adminRepository.findOrCreateRole( + user.id, + makeAdmin + ); + if (userRole.is_admin !== makeAdmin) { + userRole.is_admin = makeAdmin; + await userRole.save(); + } + } + + const userRole = await adminRepository.findRoleByUserId(user.id); + + return { + id: user.id, + email: user.email, + name: user.name, + surname: user.surname, + created_at: user.created_at, + role: userRole?.is_admin ? 'admin' : 'user', + }; + } + + /** + * Delete a user. + */ + async deleteUser(requesterId, userId) { + await this.verifyAdmin(requesterId); + + const id = validateUserId(userId); + + if (id === requesterId) { + throw new ValidationError('Cannot delete your own account'); + } + + const result = await adminRepository.deleteUserWithData( + id, + requesterId + ); + + if (!result.success) { + if (result.status === 404) { + throw new NotFoundError(result.error); + } + throw new ValidationError(result.error); + } + + return null; + } + + /** + * Toggle registration setting. + */ + async toggleRegistration(requesterId, body) { + await this.verifyAdmin(requesterId); + + const { enabled } = validateToggleRegistration(body); + + const { + setRegistrationEnabled, + } = require('../auth/registrationService'); + await setRegistrationEnabled(enabled); + + return { enabled }; + } +} + +module.exports = new AdminService(); diff --git a/backend/modules/admin/validation.js b/backend/modules/admin/validation.js new file mode 100644 index 0000000..7cb6d8d --- /dev/null +++ b/backend/modules/admin/validation.js @@ -0,0 +1,78 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); + +/** + * Validate user ID parameter. + */ +function validateUserId(id) { + const parsed = parseInt(id, 10); + if (!Number.isFinite(parsed)) { + throw new ValidationError('Invalid user id'); + } + return parsed; +} + +/** + * Validate email. + */ +function validateEmail(email) { + if (typeof email !== 'string' || !email.includes('@')) { + throw new ValidationError('Invalid email'); + } + return email; +} + +/** + * Validate password. + */ +function validatePassword(password) { + if (typeof password !== 'string' || password.length < 6) { + throw new ValidationError('Password must be at least 6 characters'); + } + return password; +} + +/** + * Validate set-admin-role request body. + */ +function validateSetAdminRole(body) { + const { user_id, is_admin } = body || {}; + if (!user_id || typeof is_admin !== 'boolean') { + throw new ValidationError('user_id and is_admin are required'); + } + return { user_id, is_admin }; +} + +/** + * Validate create user request body. + */ +function validateCreateUser(body) { + const { email, password, name, surname, role } = body || {}; + if (!email || !password) { + throw new ValidationError('Email and password are required'); + } + validateEmail(email); + validatePassword(password); + return { email, password, name, surname, role }; +} + +/** + * Validate toggle registration request body. + */ +function validateToggleRegistration(body) { + const { enabled } = body || {}; + if (typeof enabled !== 'boolean') { + throw new ValidationError('enabled must be a boolean value'); + } + return { enabled }; +} + +module.exports = { + validateUserId, + validateEmail, + validatePassword, + validateSetAdminRole, + validateCreateUser, + validateToggleRegistration, +}; diff --git a/backend/modules/areas/controller.js b/backend/modules/areas/controller.js new file mode 100644 index 0000000..2c2b9d0 --- /dev/null +++ b/backend/modules/areas/controller.js @@ -0,0 +1,104 @@ +'use strict'; + +const areasService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Areas controller - handles HTTP requests/responses. + */ +const areasController = { + /** + * GET /api/areas + * List all areas for the current user. + */ + async list(req, res, next) { + try { + const userId = requireUserId(req); + const areas = await areasService.getAll(userId); + res.json(areas); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/areas/:uid + * Get a single area by UID. + */ + async getOne(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const area = await areasService.getByUid(userId, uid); + res.json(area); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/areas + * Create a new area. + */ + async create(req, res, next) { + try { + const userId = requireUserId(req); + const { name, description } = req.body; + const area = await areasService.create(userId, { + name, + description, + }); + res.status(201).json(area); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/areas/:uid + * Update an area. + */ + async update(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const { name, description } = req.body; + const area = await areasService.update(userId, uid, { + name, + description, + }); + res.json(area); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/areas/:uid + * Delete an area. + */ + async delete(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + await areasService.delete(userId, uid); + res.status(204).send(); + } catch (error) { + next(error); + } + }, +}; + +module.exports = areasController; diff --git a/backend/modules/areas/index.js b/backend/modules/areas/index.js new file mode 100644 index 0000000..f468b0c --- /dev/null +++ b/backend/modules/areas/index.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Areas Module + * + * This module handles all area-related functionality including: + * - CRUD operations for areas + * - Area validation + * + * Usage: + * const areasModule = require('./modules/areas'); + * app.use('/api', areasModule.routes); + */ + +const routes = require('./routes'); +const areasService = require('./service'); +const areasRepository = require('./repository'); +const { validateName, validateUid } = require('./validation'); + +module.exports = { + routes, + areasService, + areasRepository, + validateName, + validateUid, +}; diff --git a/backend/modules/areas/repository.js b/backend/modules/areas/repository.js new file mode 100644 index 0000000..a3c93ad --- /dev/null +++ b/backend/modules/areas/repository.js @@ -0,0 +1,63 @@ +'use strict'; + +const { Area } = require('../../models'); +const BaseRepository = require('../../shared/database/BaseRepository'); + +const PUBLIC_ATTRIBUTES = ['uid', 'name', 'description']; +const LIST_ATTRIBUTES = ['id', 'uid', 'name', 'description']; + +class AreasRepository extends BaseRepository { + constructor() { + super(Area); + } + + /** + * Find all areas for a user, ordered by name. + */ + async findAllByUser(userId) { + return this.model.findAll({ + where: { user_id: userId }, + attributes: LIST_ATTRIBUTES, + order: [['name', 'ASC']], + }); + } + + /** + * Find an area by UID for a specific user. + */ + async findByUid(userId, uid) { + return this.model.findOne({ + where: { + uid, + user_id: userId, + }, + }); + } + + /** + * Find an area by UID with public attributes only. + */ + async findByUidPublic(userId, uid) { + return this.model.findOne({ + where: { + uid, + user_id: userId, + }, + attributes: PUBLIC_ATTRIBUTES, + }); + } + + /** + * Create a new area for a user. + */ + async createForUser(userId, { name, description }) { + return this.model.create({ + name, + description: description || '', + user_id: userId, + }); + } +} + +module.exports = new AreasRepository(); +module.exports.PUBLIC_ATTRIBUTES = PUBLIC_ATTRIBUTES; diff --git a/backend/modules/areas/routes.js b/backend/modules/areas/routes.js new file mode 100644 index 0000000..df3304b --- /dev/null +++ b/backend/modules/areas/routes.js @@ -0,0 +1,15 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const areasController = require('./controller'); + +// All routes require authentication (handled by app.js middleware) + +router.get('/areas', areasController.list); +router.get('/areas/:uid', areasController.getOne); +router.post('/areas', areasController.create); +router.patch('/areas/:uid', areasController.update); +router.delete('/areas/:uid', areasController.delete); + +module.exports = router; diff --git a/backend/modules/areas/service.js b/backend/modules/areas/service.js new file mode 100644 index 0000000..e924ecf --- /dev/null +++ b/backend/modules/areas/service.js @@ -0,0 +1,92 @@ +'use strict'; + +const _ = require('lodash'); +const areasRepository = require('./repository'); +const { PUBLIC_ATTRIBUTES } = require('./repository'); +const { validateName, validateUid } = require('./validation'); +const { NotFoundError } = require('../../shared/errors'); + +class AreasService { + /** + * Get all areas for a user. + */ + async getAll(userId) { + return areasRepository.findAllByUser(userId); + } + + /** + * Get a single area by UID. + */ + async getByUid(userId, uid) { + validateUid(uid); + + const area = await areasRepository.findByUidPublic(userId, uid); + + if (!area) { + throw new NotFoundError( + "Area not found or doesn't belong to the current user." + ); + } + + return area; + } + + /** + * Create a new area. + */ + async create(userId, { name, description }) { + const validatedName = validateName(name); + + const area = await areasRepository.createForUser(userId, { + name: validatedName, + description, + }); + + return _.pick(area, PUBLIC_ATTRIBUTES); + } + + /** + * Update an area. + */ + async update(userId, uid, { name, description }) { + validateUid(uid); + + const area = await areasRepository.findByUid(userId, uid); + + if (!area) { + throw new NotFoundError('Area not found.'); + } + + const updateData = {}; + + if (name !== undefined) { + updateData.name = name; + } + if (description !== undefined) { + updateData.description = description; + } + + await areasRepository.update(area, updateData); + + return _.pick(area, PUBLIC_ATTRIBUTES); + } + + /** + * Delete an area. + */ + async delete(userId, uid) { + validateUid(uid); + + const area = await areasRepository.findByUid(userId, uid); + + if (!area) { + throw new NotFoundError('Area not found.'); + } + + await areasRepository.destroy(area); + + return null; // 204 No Content + } +} + +module.exports = new AreasService(); diff --git a/backend/modules/areas/validation.js b/backend/modules/areas/validation.js new file mode 100644 index 0000000..70fb65d --- /dev/null +++ b/backend/modules/areas/validation.js @@ -0,0 +1,40 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); +const { isValidUid } = require('../../utils/slug-utils'); + +/** + * Validates area name. + * @param {string} name - The name to validate + * @returns {string} - The sanitized name + * @throws {ValidationError} - If validation fails + */ +function validateName(name) { + if (!name || typeof name !== 'string') { + throw new ValidationError('Area name is required.'); + } + + const trimmed = name.trim(); + + if (trimmed.length === 0) { + throw new ValidationError('Area name is required.'); + } + + return trimmed; +} + +/** + * Validates a UID parameter. + * @param {string} uid - The UID to validate + * @throws {ValidationError} - If validation fails + */ +function validateUid(uid) { + if (!isValidUid(uid)) { + throw new ValidationError('Invalid UID'); + } +} + +module.exports = { + validateName, + validateUid, +}; diff --git a/backend/modules/auth/controller.js b/backend/modules/auth/controller.js new file mode 100644 index 0000000..424c5c5 --- /dev/null +++ b/backend/modules/auth/controller.js @@ -0,0 +1,103 @@ +'use strict'; + +const authService = require('./service'); +const { logError } = require('../../services/logService'); + +const authController = { + getVersion(req, res) { + res.json(authService.getVersion()); + }, + + async getRegistrationStatus(req, res, next) { + try { + const result = await authService.getRegistrationStatus(); + res.json(result); + } catch (error) { + next(error); + } + }, + + async register(req, res, next) { + try { + const { email, password } = req.body; + const result = await authService.register(email, password); + res.status(201).json(result); + } catch (error) { + // Handle specific error messages for compatibility + if (error.statusCode === 404) { + return res.status(404).json({ error: error.message }); + } + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if ( + error.message === + 'Failed to send verification email. Please try again later.' + ) { + return res.status(500).json({ error: error.message }); + } + logError('Registration error:', error); + res.status(500).json({ + error: 'Registration failed. Please try again.', + }); + } + }, + + async verifyEmail(req, res, next) { + try { + const { token } = req.query; + const result = await authService.verifyEmail(token); + res.redirect(result.redirect); + } catch (error) { + next(error); + } + }, + + async getCurrentUser(req, res, next) { + try { + const result = await authService.getCurrentUser(req.session); + res.json(result); + } catch (error) { + logError('Error fetching current user:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + + async login(req, res, next) { + try { + const { email, password } = req.body; + const result = await authService.login( + email, + password, + req.session + ); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.statusCode === 401) { + return res.status(401).json({ errors: [error.message] }); + } + if (error.statusCode === 403) { + return res.status(403).json({ + error: error.message, + email_not_verified: error.email_not_verified || false, + }); + } + logError('Login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + + async logout(req, res, next) { + try { + const result = await authService.logout(req.session); + res.json(result); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }, +}; + +module.exports = authController; diff --git a/backend/modules/auth/index.js b/backend/modules/auth/index.js new file mode 100644 index 0000000..0389662 --- /dev/null +++ b/backend/modules/auth/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const routes = require('./routes'); +const authService = require('./service'); + +module.exports = { routes, authService }; diff --git a/backend/services/registrationService.js b/backend/modules/auth/registrationService.js similarity index 94% rename from backend/services/registrationService.js rename to backend/modules/auth/registrationService.js index bd75739..dac0c90 100644 --- a/backend/services/registrationService.js +++ b/backend/modules/auth/registrationService.js @@ -1,9 +1,9 @@ const crypto = require('crypto'); -const { User, Setting } = require('../models'); -const { getConfig } = require('../config/config'); -const { logError, logInfo } = require('./logService'); -const { sendEmail } = require('./emailService'); -const { validateEmail, validatePassword } = require('./userService'); +const { User, Setting } = require('../../models'); +const { getConfig } = require('../../config/config'); +const { logError, logInfo } = require('../../services/logService'); +const { sendEmail } = require('../../services/emailService'); +const { validateEmail, validatePassword } = require('../users/userService'); const isRegistrationEnabled = async () => { const setting = await Setting.findOne({ @@ -117,7 +117,7 @@ const verifyUserEmail = async (token) => { const sendVerificationEmail = async (user, verificationToken) => { const config = getConfig(); - const { isEmailEnabled } = require('./emailService'); + const { isEmailEnabled } = require('../../services/emailService'); if (!isEmailEnabled()) { logInfo( diff --git a/backend/modules/auth/routes.js b/backend/modules/auth/routes.js new file mode 100644 index 0000000..05b8e7e --- /dev/null +++ b/backend/modules/auth/routes.js @@ -0,0 +1,16 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const authController = require('./controller'); +const { authLimiter } = require('../../middleware/rateLimiter'); + +router.get('/version', authController.getVersion); +router.get('/registration-status', authController.getRegistrationStatus); +router.post('/register', authController.register); +router.get('/verify-email', authController.verifyEmail); +router.get('/current_user', authController.getCurrentUser); +router.post('/login', authLimiter, authController.login); +router.get('/logout', authController.logout); + +module.exports = router; diff --git a/backend/modules/auth/service.js b/backend/modules/auth/service.js new file mode 100644 index 0000000..951b49e --- /dev/null +++ b/backend/modules/auth/service.js @@ -0,0 +1,216 @@ +'use strict'; + +const { User, sequelize } = require('../../models'); +const { isAdmin } = require('../../services/rolesService'); +const { logError } = require('../../services/logService'); +const { getConfig } = require('../../config/config'); +const { + isRegistrationEnabled, + createUnverifiedUser, + sendVerificationEmail, + verifyUserEmail, +} = require('./registrationService'); +const packageJson = require('../../../package.json'); +const { + ValidationError, + NotFoundError, + UnauthorizedError, + ForbiddenError, +} = require('../../shared/errors'); + +class AuthService { + getVersion() { + return { version: packageJson.version }; + } + + async getRegistrationStatus() { + return { enabled: await isRegistrationEnabled() }; + } + + async register(email, password) { + const transaction = await sequelize.transaction(); + + try { + if (!(await isRegistrationEnabled())) { + await transaction.rollback(); + throw new NotFoundError('Registration is not enabled'); + } + + if (!email || !password) { + await transaction.rollback(); + throw new ValidationError('Email and password are required'); + } + + const { user, verificationToken } = await createUnverifiedUser( + email, + password, + transaction + ); + + const emailResult = await sendVerificationEmail( + user, + verificationToken + ); + + if (!emailResult.success) { + await transaction.rollback(); + logError( + new Error(emailResult.reason), + 'Email sending failed during registration, rolling back user creation' + ); + throw new Error( + 'Failed to send verification email. Please try again later.' + ); + } + + await transaction.commit(); + + return { + message: + 'Registration successful. Please check your email to verify your account.', + }; + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + + if (error.message === 'Email already registered') { + throw new ValidationError(error.message); + } + if ( + error.message === 'Invalid email format' || + error.message === 'Password must be at least 6 characters long' + ) { + throw new ValidationError(error.message); + } + throw error; + } + } + + async verifyEmail(token) { + if (!token) { + throw new ValidationError('Verification token is required'); + } + + try { + await verifyUserEmail(token); + const config = getConfig(); + return { redirect: `${config.frontendUrl}/login?verified=true` }; + } catch (error) { + const config = getConfig(); + let errorParam = 'invalid'; + + if (error.message === 'Email already verified') { + errorParam = 'already_verified'; + } else if (error.message === 'Verification token has expired') { + errorParam = 'expired'; + } + + logError('Email verification error:', error); + return { + redirect: `${config.frontendUrl}/login?verified=false&error=${errorParam}`, + }; + } + } + + async getCurrentUser(session) { + if (session && session.userId) { + const user = await User.findByPk(session.userId, { + attributes: [ + 'uid', + 'email', + 'name', + 'surname', + 'language', + 'appearance', + 'timezone', + 'avatar_image', + ], + }); + if (user) { + const admin = await isAdmin(user.uid); + return { + user: { + uid: user.uid, + email: user.email, + name: user.name, + surname: user.surname, + language: user.language, + appearance: user.appearance, + timezone: user.timezone, + avatar_image: user.avatar_image, + is_admin: admin, + }, + }; + } + } + + return { user: null }; + } + + async login(email, password, session) { + if (!email || !password) { + throw new ValidationError('Invalid login parameters.'); + } + + const user = await User.findOne({ where: { email } }); + if (!user) { + throw new UnauthorizedError('Invalid credentials'); + } + + const isValidPassword = await User.checkPassword( + password, + user.password_digest + ); + if (!isValidPassword) { + throw new UnauthorizedError('Invalid credentials'); + } + + if (!user.email_verified) { + const error = new ForbiddenError( + 'Please verify your email address before logging in.' + ); + error.email_not_verified = true; + throw error; + } + + session.userId = user.id; + + await new Promise((resolve, reject) => { + session.save((err) => { + if (err) reject(err); + else resolve(); + }); + }); + + const admin = await isAdmin(user.uid); + return { + user: { + uid: user.uid, + email: user.email, + name: user.name, + surname: user.surname, + language: user.language, + appearance: user.appearance, + timezone: user.timezone, + avatar_image: user.avatar_image, + is_admin: admin, + }, + }; + } + + logout(session) { + return new Promise((resolve, reject) => { + session.destroy((err) => { + if (err) { + logError('Logout error:', err); + reject(new Error('Could not log out')); + } else { + resolve({ message: 'Logged out successfully' }); + } + }); + }); + } +} + +module.exports = new AuthService(); diff --git a/backend/modules/backup/controller.js b/backend/modules/backup/controller.js new file mode 100644 index 0000000..403c56e --- /dev/null +++ b/backend/modules/backup/controller.js @@ -0,0 +1,207 @@ +'use strict'; + +const backupService = require('./service'); +const { logError } = require('../../services/logService'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +const backupController = { + async export(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.exportData(userId); + res.json(result); + } catch (error) { + logError('Error exporting user data:', error); + res.status(500).json({ + error: 'Failed to export data', + message: error.message, + }); + } + }, + + async import(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.importData( + userId, + req.file, + req.body + ); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + const response = { error: error.message }; + if (error.errors) { + response.errors = error.errors; + } + if (error.versionMessage) { + response.message = error.versionMessage; + response.backupVersion = error.backupVersion; + } + return res.status(400).json(response); + } + logError('Error importing user data:', error); + res.status(500).json({ + error: 'Failed to import data', + message: error.message, + }); + } + }, + + async validate(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.validateBackup(userId, req.file); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + const response = { valid: false }; + if (error.parseMessage) { + response.error = 'Invalid backup file'; + response.message = error.parseMessage; + } else if (error.errors) { + response.errors = error.errors; + } else if (error.versionIncompatible) { + response.versionIncompatible = true; + response.message = error.versionMessage; + response.backupVersion = error.backupVersion; + } + return res.status(400).json(response); + } + logError('Error validating backup file:', error); + res.status(500).json({ + valid: false, + error: 'Failed to validate backup file', + message: error.message, + }); + } + }, + + async list(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.listBackups(userId); + res.json(result); + } catch (error) { + logError('Error listing backups:', error); + res.status(500).json({ + error: 'Failed to list backups', + message: error.message, + }); + } + }, + + async download(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.downloadBackup( + userId, + req.params.uid + ); + + res.setHeader('Content-Type', result.contentType); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename}"` + ); + res.setHeader('Content-Length', result.fileBuffer.length); + + res.send(result.fileBuffer); + } catch (error) { + if (error.statusCode === 404) { + return res.status(404).json({ error: error.message }); + } + logError('Error downloading backup:', error); + res.status(500).json({ + error: 'Failed to download backup', + message: error.message, + }); + } + }, + + async restore(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.restoreBackup( + userId, + req.params.uid, + req.body + ); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ + error: 'Version incompatible', + message: error.versionMessage, + backupVersion: error.backupVersion, + }); + } + logError('Error restoring backup:', error); + res.status(500).json({ + error: 'Failed to restore backup', + message: error.message, + }); + } + }, + + async delete(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const result = await backupService.deleteBackup( + userId, + req.params.uid + ); + res.json(result); + } catch (error) { + logError('Error deleting backup:', error); + res.status(500).json({ + error: 'Failed to delete backup', + message: error.message, + }); + } + }, +}; + +module.exports = backupController; diff --git a/backend/modules/backup/index.js b/backend/modules/backup/index.js new file mode 100644 index 0000000..1d1f664 --- /dev/null +++ b/backend/modules/backup/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const routes = require('./routes'); +const backupService = require('./service'); + +module.exports = { routes, backupService }; diff --git a/backend/modules/backup/routes.js b/backend/modules/backup/routes.js new file mode 100644 index 0000000..72ba0f8 --- /dev/null +++ b/backend/modules/backup/routes.js @@ -0,0 +1,59 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const router = express.Router(); +const backupController = require('./controller'); + +const checkBackupsEnabled = (req, res, next) => { + const backupsEnabled = process.env.FF_ENABLE_BACKUPS === 'true'; + if (!backupsEnabled) { + return res.status(403).json({ + error: 'Backups feature is disabled', + message: + 'The backups feature is currently disabled. Please contact your administrator.', + }); + } + next(); +}; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 100 * 1024 * 1024, + }, + fileFilter: (req, file, cb) => { + const allowedMimes = [ + 'application/json', + 'application/gzip', + 'application/x-gzip', + ]; + const fileExt = file.originalname.toLowerCase(); + + if ( + allowedMimes.includes(file.mimetype) || + fileExt.endsWith('.json') || + fileExt.endsWith('.gz') + ) { + cb(null, true); + } else { + cb(new Error('Only JSON and gzip files are allowed'), false); + } + }, +}); + +router.use('/backup', checkBackupsEnabled); + +router.post('/backup/export', backupController.export); +router.post('/backup/import', upload.single('backup'), backupController.import); +router.post( + '/backup/validate', + upload.single('backup'), + backupController.validate +); +router.get('/backup/list', backupController.list); +router.get('/backup/:uid/download', backupController.download); +router.post('/backup/:uid/restore', backupController.restore); +router.delete('/backup/:uid', backupController.delete); + +module.exports = router; diff --git a/backend/modules/backup/service.js b/backend/modules/backup/service.js new file mode 100644 index 0000000..f42622e --- /dev/null +++ b/backend/modules/backup/service.js @@ -0,0 +1,220 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs').promises; +const zlib = require('zlib'); +const { promisify } = require('util'); +const { + exportUserData, + importUserData, + validateBackupData, + saveBackup, + listBackups, + getBackup, + deleteBackup, + getBackupsDirectory, + checkVersionCompatibility, +} = require('../../services/backupService'); +const { Backup } = require('../../models'); +const { NotFoundError, ValidationError } = require('../../shared/errors'); + +const gunzip = promisify(zlib.gunzip); + +async function parseUploadedBackup(fileBuffer, filename) { + let backupJson; + + const isGzipped = + filename.toLowerCase().endsWith('.gz') || + (fileBuffer[0] === 0x1f && fileBuffer[1] === 0x8b); + + if (isGzipped) { + const decompressed = await gunzip(fileBuffer); + backupJson = decompressed.toString('utf8'); + } else { + backupJson = fileBuffer.toString('utf8'); + } + + return JSON.parse(backupJson); +} + +class BackupService { + async exportData(userId) { + const backupData = await exportUserData(userId); + const backup = await saveBackup(userId, backupData); + return { + success: true, + message: 'Backup created successfully', + backup: { + uid: backup.uid, + file_size: backup.file_size, + item_counts: backup.item_counts, + created_at: backup.created_at, + }, + }; + } + + async importData(userId, file, options = {}) { + if (!file) { + throw new ValidationError('No backup file provided'); + } + + let backupData; + try { + backupData = await parseUploadedBackup( + file.buffer, + file.originalname + ); + } catch (parseError) { + throw new ValidationError( + `Invalid backup file: ${parseError.message}` + ); + } + + const validation = validateBackupData(backupData); + if (!validation.valid) { + const error = new ValidationError('Invalid backup data'); + error.errors = validation.errors; + throw error; + } + + const versionCheck = checkVersionCompatibility(backupData.version); + if (!versionCheck.compatible) { + const error = new ValidationError('Version incompatible'); + error.versionMessage = versionCheck.message; + error.backupVersion = backupData.version; + throw error; + } + + const importOptions = { + merge: options.merge !== 'false', + }; + + const stats = await importUserData(userId, backupData, importOptions); + + return { + success: true, + message: 'Backup imported successfully', + stats, + }; + } + + async validateBackup(userId, file) { + if (!file) { + throw new ValidationError('No backup file provided'); + } + + let backupData; + try { + backupData = await parseUploadedBackup( + file.buffer, + file.originalname + ); + } catch (parseError) { + const error = new ValidationError('Invalid backup file'); + error.parseMessage = parseError.message; + throw error; + } + + const validation = validateBackupData(backupData); + + if (!validation.valid) { + const error = new ValidationError('Invalid backup data'); + error.errors = validation.errors; + throw error; + } + + const versionCheck = checkVersionCompatibility(backupData.version); + if (!versionCheck.compatible) { + const error = new ValidationError('Version incompatible'); + error.versionIncompatible = true; + error.versionMessage = versionCheck.message; + error.backupVersion = backupData.version; + throw error; + } + + const summary = { + areas: backupData.data.areas?.length || 0, + projects: backupData.data.projects?.length || 0, + tasks: backupData.data.tasks?.length || 0, + tags: backupData.data.tags?.length || 0, + notes: backupData.data.notes?.length || 0, + inbox_items: backupData.data.inbox_items?.length || 0, + views: backupData.data.views?.length || 0, + }; + + return { + valid: true, + message: 'Backup file is valid', + version: backupData.version, + exported_at: backupData.exported_at, + summary, + }; + } + + async listBackups(userId) { + const backups = await listBackups(userId, 5); + return { + success: true, + backups, + }; + } + + async downloadBackup(userId, uid) { + const backup = await Backup.findOne({ + where: { uid, user_id: userId }, + }); + + if (!backup) { + throw new NotFoundError('Backup not found'); + } + + const backupsDir = await getBackupsDirectory(); + const filePath = path.join(backupsDir, backup.file_path); + + const fileBuffer = await fs.readFile(filePath); + const isCompressed = backup.file_path.endsWith('.gz'); + const filename = `tududi-backup-${new Date().toISOString().split('T')[0]}${isCompressed ? '.json.gz' : '.json'}`; + const contentType = isCompressed + ? 'application/gzip' + : 'application/json'; + + return { + fileBuffer, + filename, + contentType, + }; + } + + async restoreBackup(userId, uid, options = {}) { + const backupData = await getBackup(userId, uid); + const versionCheck = checkVersionCompatibility(backupData.version); + if (!versionCheck.compatible) { + const error = new ValidationError('Version incompatible'); + error.versionMessage = versionCheck.message; + error.backupVersion = backupData.version; + throw error; + } + + const restoreOptions = { + merge: options.merge !== false, + }; + + const stats = await importUserData(userId, backupData, restoreOptions); + + return { + success: true, + message: 'Backup restored successfully', + stats, + }; + } + + async deleteBackup(userId, uid) { + await deleteBackup(userId, uid); + return { + success: true, + message: 'Backup deleted successfully', + }; + } +} + +module.exports = new BackupService(); diff --git a/backend/modules/feature-flags/controller.js b/backend/modules/feature-flags/controller.js new file mode 100644 index 0000000..6c7ffd9 --- /dev/null +++ b/backend/modules/feature-flags/controller.js @@ -0,0 +1,16 @@ +'use strict'; + +const featureFlagsService = require('./service'); + +const featureFlagsController = { + async getAll(req, res, next) { + try { + const featureFlags = featureFlagsService.getAll(); + res.json({ featureFlags }); + } catch (error) { + next(error); + } + }, +}; + +module.exports = featureFlagsController; diff --git a/backend/modules/feature-flags/index.js b/backend/modules/feature-flags/index.js new file mode 100644 index 0000000..23caab7 --- /dev/null +++ b/backend/modules/feature-flags/index.js @@ -0,0 +1,9 @@ +'use strict'; + +const routes = require('./routes'); +const featureFlagsService = require('./service'); + +module.exports = { + routes, + featureFlagsService, +}; diff --git a/backend/modules/feature-flags/routes.js b/backend/modules/feature-flags/routes.js new file mode 100644 index 0000000..76c3283 --- /dev/null +++ b/backend/modules/feature-flags/routes.js @@ -0,0 +1,9 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const featureFlagsController = require('./controller'); + +router.get('/feature-flags', featureFlagsController.getAll); + +module.exports = router; diff --git a/backend/modules/feature-flags/service.js b/backend/modules/feature-flags/service.js new file mode 100644 index 0000000..5b4d11d --- /dev/null +++ b/backend/modules/feature-flags/service.js @@ -0,0 +1,16 @@ +'use strict'; + +class FeatureFlagsService { + /** + * Get all feature flags. + */ + getAll() { + return { + backups: process.env.FF_ENABLE_BACKUPS === 'true', + calendar: process.env.FF_ENABLE_CALENDAR === 'true', + habits: process.env.FF_ENABLE_HABITS === 'true', + }; + } +} + +module.exports = new FeatureFlagsService(); diff --git a/backend/modules/habits/controller.js b/backend/modules/habits/controller.js new file mode 100644 index 0000000..9d30cdf --- /dev/null +++ b/backend/modules/habits/controller.js @@ -0,0 +1,109 @@ +'use strict'; + +const habitsService = require('./service'); + +const habitsController = { + async getAll(req, res, next) { + try { + const result = await habitsService.getAll(req.currentUser.id); + res.json(result); + } catch (error) { + next(error); + } + }, + + async create(req, res, next) { + try { + const result = await habitsService.create( + req.currentUser.id, + req.body + ); + res.status(201).json(result); + } catch (error) { + next(error); + } + }, + + async logCompletion(req, res, next) { + try { + const result = await habitsService.logCompletion( + req.currentUser.id, + req.params.uid, + req.body.completed_at + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async getCompletions(req, res, next) { + try { + const { start_date, end_date } = req.query; + const result = await habitsService.getCompletions( + req.currentUser.id, + req.params.uid, + start_date, + end_date + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async deleteCompletion(req, res, next) { + try { + const result = await habitsService.deleteCompletion( + req.currentUser.id, + req.params.uid, + req.params.completionId + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async getStats(req, res, next) { + try { + const { start_date, end_date } = req.query; + const result = await habitsService.getStats( + req.currentUser.id, + req.params.uid, + start_date, + end_date + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async update(req, res, next) { + try { + const result = await habitsService.update( + req.currentUser.id, + req.params.uid, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async delete(req, res, next) { + try { + const result = await habitsService.delete( + req.currentUser.id, + req.params.uid + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = habitsController; diff --git a/backend/services/habitService.js b/backend/modules/habits/habitService.js similarity index 98% rename from backend/services/habitService.js rename to backend/modules/habits/habitService.js index a86abd9..95997e6 100644 --- a/backend/services/habitService.js +++ b/backend/modules/habits/habitService.js @@ -1,4 +1,4 @@ -const { RecurringCompletion } = require('../models'); +const { RecurringCompletion } = require('../../models'); const { Op } = require('sequelize'); class HabitService { @@ -257,7 +257,9 @@ class HabitService { } else { // Strict: check if today matches recurrence pattern // Leverage existing recurringTaskService logic - const { calculateNextDueDate } = require('./recurringTaskService'); + const { + calculateNextDueDate, + } = require('../tasks/recurringTaskService'); const nextDue = calculateNextDueDate( task, task.habit_last_completion_at || task.created_at diff --git a/backend/modules/habits/index.js b/backend/modules/habits/index.js new file mode 100644 index 0000000..4965aea --- /dev/null +++ b/backend/modules/habits/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const routes = require('./routes'); +const habitsService = require('./service'); +const habitsRepository = require('./repository'); + +module.exports = { routes, habitsService, habitsRepository }; diff --git a/backend/modules/habits/repository.js b/backend/modules/habits/repository.js new file mode 100644 index 0000000..8e4138e --- /dev/null +++ b/backend/modules/habits/repository.js @@ -0,0 +1,56 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { Task, RecurringCompletion } = require('../../models'); +const { Op } = require('sequelize'); + +class HabitsRepository extends BaseRepository { + constructor() { + super(Task); + } + + async findAllByUser(userId) { + return this.model.findAll({ + where: { + user_id: userId, + habit_mode: true, + status: { [Op.ne]: 3 }, + }, + order: [['created_at', 'DESC']], + }); + } + + async findByUidAndUser(uid, userId) { + return this.model.findOne({ + where: { uid, user_id: userId }, + }); + } + + async createHabit(userId, data) { + return this.model.create({ + ...data, + user_id: userId, + habit_mode: true, + status: 0, + }); + } + + async findCompletions(taskId, startDate, endDate) { + return RecurringCompletion.findAll({ + where: { + task_id: taskId, + skipped: false, + completed_at: { [Op.between]: [startDate, endDate] }, + }, + order: [['completed_at', 'DESC']], + }); + } + + async findCompletionById(completionId, taskId) { + return RecurringCompletion.findOne({ + where: { id: completionId, task_id: taskId }, + }); + } +} + +module.exports = new HabitsRepository(); diff --git a/backend/modules/habits/routes.js b/backend/modules/habits/routes.js new file mode 100644 index 0000000..dd812a8 --- /dev/null +++ b/backend/modules/habits/routes.js @@ -0,0 +1,29 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const habitsController = require('./controller'); +const { requireAuth } = require('../../middleware/auth'); + +router.get('/habits', requireAuth, habitsController.getAll); +router.post('/habits', requireAuth, habitsController.create); +router.post( + '/habits/:uid/complete', + requireAuth, + habitsController.logCompletion +); +router.get( + '/habits/:uid/completions', + requireAuth, + habitsController.getCompletions +); +router.delete( + '/habits/:uid/completions/:completionId', + requireAuth, + habitsController.deleteCompletion +); +router.get('/habits/:uid/stats', requireAuth, habitsController.getStats); +router.put('/habits/:uid', requireAuth, habitsController.update); +router.delete('/habits/:uid', requireAuth, habitsController.delete); + +module.exports = router; diff --git a/backend/modules/habits/service.js b/backend/modules/habits/service.js new file mode 100644 index 0000000..b3a271f --- /dev/null +++ b/backend/modules/habits/service.js @@ -0,0 +1,96 @@ +'use strict'; + +const habitsRepository = require('./repository'); +const habitService = require('./habitService'); +const { NotFoundError } = require('../../shared/errors'); + +class HabitsService { + async getAll(userId) { + const habits = await habitsRepository.findAllByUser(userId); + return { habits }; + } + + async create(userId, data) { + const habit = await habitsRepository.createHabit(userId, data); + return { habit }; + } + + async logCompletion(userId, uid, completedAt) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + const result = await habitService.logCompletion( + habit, + completedAt ? new Date(completedAt) : new Date() + ); + return result; + } + + async getCompletions(userId, uid, startDate, endDate) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + const start = startDate + ? new Date(startDate) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = endDate ? new Date(endDate) : new Date(); + const completions = await habitsRepository.findCompletions( + habit.id, + start, + end + ); + return { completions }; + } + + async deleteCompletion(userId, uid, completionId) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + const completion = await habitsRepository.findCompletionById( + completionId, + habit.id + ); + if (!completion) { + throw new NotFoundError('Completion not found'); + } + await completion.destroy(); + const updates = await habitService.recalculateStreaks(habit); + await habitsRepository.update(habit, updates); + return { message: 'Completion deleted', task: habit }; + } + + async getStats(userId, uid, startDate, endDate) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + const start = startDate + ? new Date(startDate) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = endDate ? new Date(endDate) : new Date(); + return habitService.getHabitStats(habit, start, end); + } + + async update(userId, uid, data) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + await habitsRepository.update(habit, data); + return { habit }; + } + + async delete(userId, uid) { + const habit = await habitsRepository.findByUidAndUser(uid, userId); + if (!habit || !habit.habit_mode) { + throw new NotFoundError('Habit not found'); + } + await habitsRepository.destroy(habit); + return { message: 'Habit deleted' }; + } +} + +module.exports = new HabitsService(); diff --git a/backend/modules/inbox/controller.js b/backend/modules/inbox/controller.js new file mode 100644 index 0000000..5431df5 --- /dev/null +++ b/backend/modules/inbox/controller.js @@ -0,0 +1,131 @@ +'use strict'; + +const inboxService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Inbox controller - handles HTTP requests/responses. + */ +const inboxController = { + /** + * GET /api/inbox + * List all active inbox items for the current user. + */ + async list(req, res, next) { + try { + const userId = requireUserId(req); + const { limit, offset } = req.query; + const result = await inboxService.getAll(userId, { limit, offset }); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/inbox/:uid + * Get a single inbox item by UID. + */ + async getOne(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const item = await inboxService.getByUid(userId, uid); + res.json(item); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/inbox + * Create a new inbox item. + */ + async create(req, res, next) { + try { + const userId = requireUserId(req); + const { content, source } = req.body; + const item = await inboxService.create(userId, { content, source }); + res.status(201).json(item); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/inbox/:uid + * Update an inbox item. + */ + async update(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const { content, status } = req.body; + const item = await inboxService.update(userId, uid, { + content, + status, + }); + res.json(item); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/inbox/:uid + * Soft delete an inbox item. + */ + async delete(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const result = await inboxService.delete(userId, uid); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/inbox/:uid/process + * Mark an inbox item as processed. + */ + async process(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const item = await inboxService.process(userId, uid); + res.json(item); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/inbox/analyze-text + * Analyze text content without creating an inbox item. + */ + async analyzeText(req, res, next) { + try { + const { content } = req.body; + const result = inboxService.analyzeText(content); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = inboxController; diff --git a/backend/services/inboxProcessingService.js b/backend/modules/inbox/inboxProcessingService.js similarity index 100% rename from backend/services/inboxProcessingService.js rename to backend/modules/inbox/inboxProcessingService.js diff --git a/backend/modules/inbox/index.js b/backend/modules/inbox/index.js new file mode 100644 index 0000000..26649f9 --- /dev/null +++ b/backend/modules/inbox/index.js @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Inbox Module + * + * This module handles all inbox-related functionality including: + * - CRUD operations for inbox items + * - Text analysis for inbox content + * - Pagination support + * + * Usage: + * const inboxModule = require('./modules/inbox'); + * app.use('/api', inboxModule.routes); + */ + +const routes = require('./routes'); +const inboxService = require('./service'); +const inboxRepository = require('./repository'); +const { + validateContent, + validateUid, + buildTitleFromContent, +} = require('./validation'); + +module.exports = { + routes, + inboxService, + inboxRepository, + validateContent, + validateUid, + buildTitleFromContent, +}; diff --git a/backend/modules/inbox/repository.js b/backend/modules/inbox/repository.js new file mode 100644 index 0000000..6f154e7 --- /dev/null +++ b/backend/modules/inbox/repository.js @@ -0,0 +1,117 @@ +'use strict'; + +const { InboxItem } = require('../../models'); +const BaseRepository = require('../../shared/database/BaseRepository'); + +const PUBLIC_ATTRIBUTES = [ + 'uid', + 'title', + 'content', + 'status', + 'source', + 'created_at', + 'updated_at', +]; + +class InboxRepository extends BaseRepository { + constructor() { + super(InboxItem); + } + + /** + * Find all active inbox items for a user (status = 'added'). + */ + async findAllActive(userId, { limit, offset } = {}) { + const options = { + where: { + user_id: userId, + status: 'added', + }, + order: [['created_at', 'DESC']], + }; + + if (limit !== undefined) { + options.limit = limit; + options.offset = offset || 0; + } + + return this.model.findAll(options); + } + + /** + * Count active inbox items for a user. + */ + async countActive(userId) { + return this.model.count({ + where: { + user_id: userId, + status: 'added', + }, + raw: true, + }); + } + + /** + * Find an inbox item by UID for a specific user. + */ + async findByUid(userId, uid) { + return this.model.findOne({ + where: { + uid, + user_id: userId, + }, + }); + } + + /** + * Find an inbox item by UID with limited public attributes. + */ + async findByUidPublic(userId, uid) { + return this.model.findOne({ + where: { + uid, + user_id: userId, + }, + attributes: PUBLIC_ATTRIBUTES, + }); + } + + /** + * Create a new inbox item for a user. + */ + async createForUser(userId, { content, title, source }) { + return this.model.create({ + content, + title, + source, + user_id: userId, + }); + } + + /** + * Update an inbox item. + */ + async updateItem(item, data) { + await item.update(data); + return item; + } + + /** + * Soft delete an inbox item (mark as 'deleted'). + */ + async softDelete(item) { + await item.update({ status: 'deleted' }); + return item; + } + + /** + * Mark an inbox item as processed. + */ + async markProcessed(item) { + await item.update({ status: 'processed' }); + return item; + } +} + +module.exports = new InboxRepository(); +module.exports.PUBLIC_ATTRIBUTES = PUBLIC_ATTRIBUTES; diff --git a/backend/modules/inbox/routes.js b/backend/modules/inbox/routes.js new file mode 100644 index 0000000..338cbef --- /dev/null +++ b/backend/modules/inbox/routes.js @@ -0,0 +1,17 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const inboxController = require('./controller'); + +// All routes require authentication (handled by app.js middleware) + +router.get('/inbox', inboxController.list); +router.post('/inbox', inboxController.create); +router.post('/inbox/analyze-text', inboxController.analyzeText); +router.get('/inbox/:uid', inboxController.getOne); +router.patch('/inbox/:uid', inboxController.update); +router.delete('/inbox/:uid', inboxController.delete); +router.patch('/inbox/:uid/process', inboxController.process); + +module.exports = router; diff --git a/backend/modules/inbox/service.js b/backend/modules/inbox/service.js new file mode 100644 index 0000000..490e0bc --- /dev/null +++ b/backend/modules/inbox/service.js @@ -0,0 +1,154 @@ +'use strict'; + +const _ = require('lodash'); +const inboxRepository = require('./repository'); +const { PUBLIC_ATTRIBUTES } = require('./repository'); +const { + validateContent, + validateUid, + validateSource, + buildTitleFromContent, +} = require('./validation'); +const { NotFoundError } = require('../../shared/errors'); +const { processInboxItem } = require('./inboxProcessingService'); + +class InboxService { + /** + * Get all active inbox items for a user. + * Supports pagination if limit/offset provided. + */ + async getAll(userId, { limit, offset } = {}) { + const hasPagination = limit !== undefined || offset !== undefined; + + if (hasPagination) { + const parsedLimit = parseInt(limit, 10) || 20; + const parsedOffset = parseInt(offset, 10) || 0; + + const [items, totalCount] = await Promise.all([ + inboxRepository.findAllActive(userId, { + limit: parsedLimit, + offset: parsedOffset, + }), + inboxRepository.countActive(userId), + ]); + + return { + items, + pagination: { + total: totalCount, + limit: parsedLimit, + offset: parsedOffset, + hasMore: parsedOffset + items.length < totalCount, + }, + }; + } + + // Return simple array for backward compatibility + return inboxRepository.findAllActive(userId); + } + + /** + * Get a single inbox item by UID. + */ + async getByUid(userId, uid) { + validateUid(uid); + + const item = await inboxRepository.findByUidPublic(userId, uid); + + if (!item) { + throw new NotFoundError('Inbox item not found.'); + } + + return item; + } + + /** + * Create a new inbox item. + */ + async create(userId, { content, source }) { + const validatedContent = validateContent(content); + const validatedSource = validateSource(source); + const title = buildTitleFromContent(validatedContent); + + const item = await inboxRepository.createForUser(userId, { + content: validatedContent, + title, + source: validatedSource, + }); + + return _.pick(item, PUBLIC_ATTRIBUTES); + } + + /** + * Update an inbox item. + */ + async update(userId, uid, { content, status }) { + validateUid(uid); + + const item = await inboxRepository.findByUid(userId, uid); + + if (!item) { + throw new NotFoundError('Inbox item not found.'); + } + + const updateData = {}; + + if (content !== undefined && content !== null) { + const validatedContent = validateContent(content); + updateData.content = validatedContent; + updateData.title = buildTitleFromContent(validatedContent); + } + + if (status !== undefined && status !== null) { + updateData.status = status; + } + + await inboxRepository.updateItem(item, updateData); + + return _.pick(item, PUBLIC_ATTRIBUTES); + } + + /** + * Soft delete an inbox item. + */ + async delete(userId, uid) { + validateUid(uid); + + const item = await inboxRepository.findByUid(userId, uid); + + if (!item) { + throw new NotFoundError('Inbox item not found.'); + } + + await inboxRepository.softDelete(item); + + return { message: 'Inbox item successfully deleted' }; + } + + /** + * Mark an inbox item as processed. + */ + async process(userId, uid) { + validateUid(uid); + + const item = await inboxRepository.findByUid(userId, uid); + + if (!item) { + throw new NotFoundError('Inbox item not found.'); + } + + await inboxRepository.markProcessed(item); + + return _.pick(item, PUBLIC_ATTRIBUTES); + } + + /** + * Analyze text content without creating an inbox item. + */ + analyzeText(content) { + validateContent(content); + return processInboxItem(content); + } +} + +module.exports = new InboxService(); diff --git a/backend/modules/inbox/validation.js b/backend/modules/inbox/validation.js new file mode 100644 index 0000000..152cb4c --- /dev/null +++ b/backend/modules/inbox/validation.js @@ -0,0 +1,70 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); +const { isValidUid } = require('../../utils/slug-utils'); + +const TITLE_MAX_LENGTH = 120; + +/** + * Validates and sanitizes inbox item content. + * @param {string} content - The content to validate + * @returns {string} - The sanitized content + * @throws {ValidationError} - If validation fails + */ +function validateContent(content) { + if (!content || typeof content !== 'string') { + throw new ValidationError('Content is required'); + } + + const trimmed = content.trim(); + + if (trimmed.length === 0) { + throw new ValidationError('Content cannot be empty'); + } + + return trimmed; +} + +/** + * Validates a UID parameter. + * @param {string} uid - The UID to validate + * @throws {ValidationError} - If validation fails + */ +function validateUid(uid) { + if (!isValidUid(uid)) { + throw new ValidationError('Invalid UID'); + } +} + +/** + * Builds a title from content (truncated if necessary). + * @param {string} content - The content to build title from + * @returns {string} - The generated title + */ +function buildTitleFromContent(content) { + const normalized = content.trim(); + if (normalized.length <= TITLE_MAX_LENGTH) { + return normalized; + } + return `${normalized.slice(0, TITLE_MAX_LENGTH).trim()}...`; +} + +/** + * Validates source field. + * @param {string|undefined} source - The source to validate + * @returns {string} - The validated source (defaults to 'manual') + */ +function validateSource(source) { + if (!source || typeof source !== 'string' || !source.trim()) { + return 'manual'; + } + return source.trim(); +} + +module.exports = { + validateContent, + validateUid, + buildTitleFromContent, + validateSource, + TITLE_MAX_LENGTH, +}; diff --git a/backend/modules/notes/controller.js b/backend/modules/notes/controller.js new file mode 100644 index 0000000..849e648 --- /dev/null +++ b/backend/modules/notes/controller.js @@ -0,0 +1,128 @@ +'use strict'; + +const notesService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); +const { extractUidFromSlug } = require('../../utils/slug-utils'); + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Notes controller - handles HTTP requests/responses. + */ +const notesController = { + /** + * GET /api/notes + * List all notes for the current user. + */ + async list(req, res, next) { + try { + const userId = requireUserId(req); + const notes = await notesService.getAll(userId, { + orderBy: req.query.order_by, + tagFilter: req.query.tag, + }); + res.json(notes); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/note/:uidSlug + * Get a single note by UID. + */ + async getOne(req, res, next) { + try { + const uid = extractUidFromSlug(req.params.uidSlug); + const note = await notesService.getByUid(uid); + res.json(note); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/note + * Create a new note. + */ + async create(req, res, next) { + try { + const userId = requireUserId(req); + const { title, content, project_uid, project_id, tags, color } = + req.body; + + const note = await notesService.create(userId, { + title, + content, + project_uid, + project_id, + tags, + color, + }); + + res.status(201).json(note); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/note/:uid + * Update a note. + */ + async update(req, res, next) { + try { + const userId = requireUserId(req); + const { uid } = req.params; + const { title, content, project_uid, project_id, tags, color } = + req.body; + + const note = await notesService.update(userId, uid, { + title, + content, + project_uid, + project_id, + tags, + color, + }); + + res.json(note); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/note/:uid + * Delete a note. + */ + async delete(req, res, next) { + try { + const { uid } = req.params; + const result = await notesService.delete(uid); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * Get note UID if exists (for authorization middleware). + */ + async getNoteUidForAuth(req) { + const uid = extractUidFromSlug(req.params.uidSlug || req.params.uid); + return notesService.getNoteUidIfExists(uid); + }, +}; + +module.exports = notesController; diff --git a/backend/modules/notes/index.js b/backend/modules/notes/index.js new file mode 100644 index 0000000..d90c019 --- /dev/null +++ b/backend/modules/notes/index.js @@ -0,0 +1,28 @@ +'use strict'; + +/** + * Notes Module + * + * This module handles all note-related functionality including: + * - CRUD operations for notes + * - Tag management for notes + * - Project association with permission checks + * - Note validation + * + * Usage: + * const notesModule = require('./modules/notes'); + * app.use('/api', notesModule.routes); + */ + +const routes = require('./routes'); +const notesService = require('./service'); +const notesRepository = require('./repository'); +const { validateUid, validateTitle } = require('./validation'); + +module.exports = { + routes, + notesService, + notesRepository, + validateUid, + validateTitle, +}; diff --git a/backend/modules/notes/repository.js b/backend/modules/notes/repository.js new file mode 100644 index 0000000..8b4a771 --- /dev/null +++ b/backend/modules/notes/repository.js @@ -0,0 +1,119 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { Note, Tag, Project } = require('../../models'); + +const PUBLIC_ATTRIBUTES = [ + 'uid', + 'title', + 'content', + 'color', + 'createdAt', + 'updatedAt', +]; + +const TAG_INCLUDE = { + model: Tag, + attributes: ['name', 'uid'], + through: { attributes: [] }, +}; + +const PROJECT_INCLUDE = { + model: Project, + required: false, + attributes: ['name', 'uid'], +}; + +const TAG_INCLUDE_WITH_ID = { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, +}; + +const PROJECT_INCLUDE_WITH_ID = { + model: Project, + required: false, + attributes: ['id', 'name', 'uid'], +}; + +class NotesRepository extends BaseRepository { + constructor() { + super(Note); + } + + /** + * Find all notes by where clause with includes. + */ + async findAllWithIncludes(whereClause, options = {}) { + const { + orderColumn = 'title', + orderDirection = 'ASC', + tagFilter, + } = options; + + const includeClause = [ + tagFilter + ? { ...TAG_INCLUDE, where: { name: tagFilter }, required: true } + : TAG_INCLUDE, + PROJECT_INCLUDE, + ]; + + return this.model.findAll({ + where: whereClause, + include: includeClause, + order: [[orderColumn, orderDirection]], + distinct: true, + }); + } + + /** + * Find a note by UID with includes. + */ + async findByUidWithIncludes(uid) { + return this.model.findOne({ + where: { uid }, + include: [TAG_INCLUDE, PROJECT_INCLUDE], + }); + } + + /** + * Find a note by UID (simple, for existence check). + */ + async findByUid(uid) { + return this.model.findOne({ + where: { uid }, + attributes: ['id', 'uid', 'user_id'], + }); + } + + /** + * Find a note by ID with includes (for reloading after create/update). + */ + async findByIdWithIncludes(id) { + return this.model.findByPk(id, { + include: [TAG_INCLUDE, PROJECT_INCLUDE], + }); + } + + /** + * Find a note by ID with detailed includes (including id attributes). + */ + async findByIdWithDetailedIncludes(id) { + return this.model.findByPk(id, { + include: [TAG_INCLUDE_WITH_ID, PROJECT_INCLUDE_WITH_ID], + }); + } + + /** + * Create a note for a user. + */ + async createForUser(userId, data) { + return this.model.create({ + ...data, + user_id: userId, + }); + } +} + +module.exports = new NotesRepository(); +module.exports.PUBLIC_ATTRIBUTES = PUBLIC_ATTRIBUTES; diff --git a/backend/modules/notes/routes.js b/backend/modules/notes/routes.js new file mode 100644 index 0000000..01847a1 --- /dev/null +++ b/backend/modules/notes/routes.js @@ -0,0 +1,43 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const notesController = require('./controller'); +const { hasAccess } = require('../../middleware/authorize'); + +// All routes require authentication (handled by app.js middleware) + +// List all notes +router.get('/notes', notesController.list); + +// Get a single note (requires read access) +router.get( + '/note/:uidSlug', + hasAccess('ro', 'note', (req) => notesController.getNoteUidForAuth(req), { + notFoundMessage: 'Note not found.', + }), + notesController.getOne +); + +// Create a new note +router.post('/note', notesController.create); + +// Update a note (requires write access) +router.patch( + '/note/:uid', + hasAccess('rw', 'note', (req) => notesController.getNoteUidForAuth(req), { + notFoundMessage: 'Note not found.', + }), + notesController.update +); + +// Delete a note (requires write access) +router.delete( + '/note/:uid', + hasAccess('rw', 'note', (req) => notesController.getNoteUidForAuth(req), { + notFoundMessage: 'Note not found.', + }), + notesController.delete +); + +module.exports = router; diff --git a/backend/modules/notes/service.js b/backend/modules/notes/service.js new file mode 100644 index 0000000..ca4b7a1 --- /dev/null +++ b/backend/modules/notes/service.js @@ -0,0 +1,309 @@ +'use strict'; + +const _ = require('lodash'); +const notesRepository = require('./repository'); +const { validateUid } = require('./validation'); +const { + NotFoundError, + ValidationError, + ForbiddenError, +} = require('../../shared/errors'); +const { Tag, Project } = require('../../models'); +const { validateTagName } = require('../tags/tagsService'); +const permissionsService = require('../../services/permissionsService'); +const { sortTags } = require('../tasks/core/serializers'); +const { logError } = require('../../services/logService'); + +/** + * Serialize a note with sorted tags. + */ +function serializeNote(note) { + const noteJson = note.toJSON ? note.toJSON() : note; + return { + ...noteJson, + Tags: sortTags(noteJson.Tags), + }; +} + +/** + * Parse tags from request body (array of strings or objects with name). + */ +function parseTagsFromBody(tags) { + if (!Array.isArray(tags)) { + return []; + } + if (tags.every((t) => typeof t === 'string')) { + return tags; + } + if (tags.every((t) => typeof t === 'object' && t.name)) { + return tags.map((t) => t.name); + } + return []; +} + +/** + * Update note tags. + */ +async function updateNoteTags(note, tagsArray, userId) { + if (_.isEmpty(tagsArray)) { + await note.setTags([]); + return; + } + + // Validate and filter tag names + const validTagNames = []; + const invalidTags = []; + + for (const name of tagsArray) { + const validation = validateTagName(name); + if (validation.valid) { + if (!validTagNames.includes(validation.name)) { + validTagNames.push(validation.name); + } + } else { + invalidTags.push({ name, error: validation.error }); + } + } + + if (invalidTags.length > 0) { + throw new ValidationError( + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` + ); + } + + const tags = await Promise.all( + validTagNames.map(async (name) => { + const [tag] = await Tag.findOrCreate({ + where: { name, user_id: userId }, + defaults: { name, user_id: userId }, + }); + return tag; + }) + ); + await note.setTags(tags); +} + +/** + * Resolve project from UID or ID and check write access. + */ +async function resolveProjectWithAccess(userId, projectUid, projectId) { + const projectIdentifier = projectUid || projectId; + + if (!projectIdentifier || _.isEmpty(projectIdentifier.toString().trim())) { + return null; + } + + let project; + if (projectUid) { + const projectUidValue = projectUid.toString().trim(); + project = await Project.findOne({ where: { uid: projectUidValue } }); + } else { + project = await Project.findByPk(projectId); + } + + if (!project) { + throw new NotFoundError('Note project not found'); + } + + const projectAccess = await permissionsService.getAccess( + userId, + 'project', + project.uid + ); + const isOwner = project.user_id === userId; + const canWrite = + isOwner || projectAccess === 'rw' || projectAccess === 'admin'; + + if (!canWrite) { + throw new ForbiddenError('Forbidden'); + } + + return project; +} + +class NotesService { + /** + * Get all notes for a user with optional filtering. + */ + async getAll(userId, options = {}) { + const { orderBy = 'title:asc', tagFilter } = options; + const [orderColumn, orderDirection] = orderBy.split(':'); + + const whereClause = await permissionsService.ownershipOrPermissionWhere( + 'note', + userId + ); + + const notes = await notesRepository.findAllWithIncludes(whereClause, { + orderColumn, + orderDirection: orderDirection.toUpperCase(), + tagFilter, + }); + + return notes.map(serializeNote); + } + + /** + * Get a note by UID. + */ + async getByUid(uid) { + const validatedUid = validateUid(uid); + const note = await notesRepository.findByUidWithIncludes(validatedUid); + + if (!note) { + throw new NotFoundError('Note not found.'); + } + + return serializeNote(note); + } + + /** + * Check if a note exists and return its UID (for authorization middleware). + */ + async getNoteUidIfExists(uid) { + const validatedUid = validateUid(uid); + const note = await notesRepository.findByUid(validatedUid); + return note ? note.uid : null; + } + + /** + * Create a new note. + */ + async create( + userId, + { title, content, project_uid, project_id, tags, color } + ) { + const noteAttributes = { title, content }; + + if (color !== undefined) { + noteAttributes.color = color; + } + + // Handle project assignment with permission check + const project = await resolveProjectWithAccess( + userId, + project_uid, + project_id + ); + if (project) { + noteAttributes.project_id = project.id; + } + + const note = await notesRepository.createForUser( + userId, + noteAttributes + ); + + // Handle tags + const tagNames = parseTagsFromBody(tags); + await updateNoteTags(note, tagNames, userId); + + // Reload with associations + const noteWithAssociations = await notesRepository.findByIdWithIncludes( + note.id + ); + const serialized = serializeNote(noteWithAssociations); + + return { + ...serialized, + uid: noteWithAssociations.uid, + }; + } + + /** + * Update a note. + */ + async update( + userId, + uid, + { title, content, project_uid, project_id, tags, color } + ) { + const validatedUid = validateUid(uid); + const note = await notesRepository.findOne({ uid: validatedUid }); + + if (!note) { + throw new NotFoundError('Note not found.'); + } + + const updateData = {}; + if (title !== undefined) updateData.title = title; + if (content !== undefined) updateData.content = content; + if (color !== undefined) updateData.color = color; + + // Handle project assignment + const projectIdentifier = + project_uid !== undefined ? project_uid : project_id; + + if (projectIdentifier !== undefined) { + if (projectIdentifier && projectIdentifier.toString().trim()) { + let project; + + if ( + project_uid !== undefined && + typeof project_uid === 'string' + ) { + const projectUidValue = project_uid.trim(); + project = await Project.findOne({ + where: { uid: projectUidValue }, + }); + } else if (project_id !== undefined) { + project = await Project.findByPk(project_id); + } + + if (!project) { + throw new ValidationError('Invalid project.'); + } + + const projectAccess = await permissionsService.getAccess( + userId, + 'project', + project.uid + ); + const isOwner = project.user_id === userId; + const canWrite = + isOwner || + projectAccess === 'rw' || + projectAccess === 'admin'; + + if (!canWrite) { + throw new ForbiddenError('Forbidden'); + } + + updateData.project_id = project.id; + } else { + updateData.project_id = null; + } + } + + await notesRepository.update(note, updateData); + + // Handle tags if provided + if (tags !== undefined) { + const tagNames = parseTagsFromBody(tags); + await updateNoteTags(note, tagNames, userId); + } + + // Reload with associations + const noteWithAssociations = + await notesRepository.findByIdWithDetailedIncludes(note.id); + return serializeNote(noteWithAssociations); + } + + /** + * Delete a note. + */ + async delete(uid) { + const validatedUid = validateUid(uid); + const note = await notesRepository.findOne({ uid: validatedUid }); + + if (!note) { + throw new NotFoundError('Note not found.'); + } + + await notesRepository.destroy(note); + return { message: 'Note deleted successfully.' }; + } +} + +module.exports = new NotesService(); +module.exports.serializeNote = serializeNote; diff --git a/backend/modules/notes/validation.js b/backend/modules/notes/validation.js new file mode 100644 index 0000000..c8247ec --- /dev/null +++ b/backend/modules/notes/validation.js @@ -0,0 +1,47 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); +const { isValidUid } = require('../../utils/slug-utils'); + +/** + * Validate a note UID. + * @param {string} uid - The UID to validate. + * @returns {string} The validated UID. + * @throws {ValidationError} If the UID is invalid. + */ +function validateUid(uid) { + if (!uid || typeof uid !== 'string') { + throw new ValidationError('Note UID is required.'); + } + + const trimmedUid = uid.trim(); + if (!trimmedUid) { + throw new ValidationError('Note UID cannot be empty.'); + } + + if (!isValidUid(trimmedUid)) { + throw new ValidationError('Invalid note UID format.'); + } + + return trimmedUid; +} + +/** + * Validate note title. + * @param {string} title - The title to validate. + * @returns {string} The validated title. + * @throws {ValidationError} If the title is invalid. + */ +function validateTitle(title) { + if (title !== undefined && title !== null) { + if (typeof title !== 'string') { + throw new ValidationError('Note title must be a string.'); + } + } + return title; +} + +module.exports = { + validateUid, + validateTitle, +}; diff --git a/backend/modules/notifications/controller.js b/backend/modules/notifications/controller.js new file mode 100644 index 0000000..dddc5f2 --- /dev/null +++ b/backend/modules/notifications/controller.js @@ -0,0 +1,86 @@ +'use strict'; + +const notificationsService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +const notificationsController = { + async getAll(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.getAll(userId, req.query); + res.json(result); + } catch (error) { + next(error); + } + }, + + async getUnreadCount(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.getUnreadCount(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + async markAsRead(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.markAsRead( + userId, + req.params.id + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async markAsUnread(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.markAsUnread( + userId, + req.params.id + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + async markAllAsRead(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.markAllAsRead(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + async dismiss(req, res, next) { + try { + const userId = requireUserId(req); + const result = await notificationsService.dismiss( + userId, + req.params.id + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = notificationsController; diff --git a/backend/modules/notifications/index.js b/backend/modules/notifications/index.js new file mode 100644 index 0000000..0d36e60 --- /dev/null +++ b/backend/modules/notifications/index.js @@ -0,0 +1,11 @@ +'use strict'; + +const routes = require('./routes'); +const notificationsService = require('./service'); +const notificationsRepository = require('./repository'); + +module.exports = { + routes, + notificationsService, + notificationsRepository, +}; diff --git a/backend/modules/notifications/repository.js b/backend/modules/notifications/repository.js new file mode 100644 index 0000000..6914c10 --- /dev/null +++ b/backend/modules/notifications/repository.js @@ -0,0 +1,30 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { Notification } = require('../../models'); + +class NotificationsRepository extends BaseRepository { + constructor() { + super(Notification); + } + + async getUserNotifications(userId, options) { + return Notification.getUserNotifications(userId, options); + } + + async getUnreadCount(userId) { + return Notification.getUnreadCount(userId); + } + + async markAllAsRead(userId) { + return Notification.markAllAsRead(userId); + } + + async findByIdAndUser(id, userId, options = {}) { + return this.model.findOne({ + where: { id, user_id: userId, ...options }, + }); + } +} + +module.exports = new NotificationsRepository(); diff --git a/backend/modules/notifications/routes.js b/backend/modules/notifications/routes.js new file mode 100644 index 0000000..d1b3570 --- /dev/null +++ b/backend/modules/notifications/routes.js @@ -0,0 +1,20 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const notificationsController = require('./controller'); + +router.get('/notifications', notificationsController.getAll); +router.get( + '/notifications/unread-count', + notificationsController.getUnreadCount +); +router.post('/notifications/:id/read', notificationsController.markAsRead); +router.post('/notifications/:id/unread', notificationsController.markAsUnread); +router.post( + '/notifications/mark-all-read', + notificationsController.markAllAsRead +); +router.delete('/notifications/:id', notificationsController.dismiss); + +module.exports = router; diff --git a/backend/modules/notifications/service.js b/backend/modules/notifications/service.js new file mode 100644 index 0000000..625f2ca --- /dev/null +++ b/backend/modules/notifications/service.js @@ -0,0 +1,65 @@ +'use strict'; + +const notificationsRepository = require('./repository'); +const { NotFoundError } = require('../../shared/errors'); + +class NotificationsService { + async getAll(userId, options) { + const { limit = 10, offset = 0, includeRead = 'true', type } = options; + return notificationsRepository.getUserNotifications(userId, { + limit: parseInt(limit), + offset: parseInt(offset), + includeRead: includeRead === 'true', + type: type || null, + }); + } + + async getUnreadCount(userId) { + const count = await notificationsRepository.getUnreadCount(userId); + return { count }; + } + + async markAsRead(userId, notificationId) { + const notification = await notificationsRepository.findByIdAndUser( + notificationId, + userId + ); + if (!notification) { + throw new NotFoundError('Notification not found'); + } + await notification.markAsRead(); + return { notification, message: 'Notification marked as read' }; + } + + async markAsUnread(userId, notificationId) { + const notification = await notificationsRepository.findByIdAndUser( + notificationId, + userId + ); + if (!notification) { + throw new NotFoundError('Notification not found'); + } + await notification.markAsUnread(); + return { notification, message: 'Notification marked as unread' }; + } + + async markAllAsRead(userId) { + const [count] = await notificationsRepository.markAllAsRead(userId); + return { count, message: `Marked ${count} notifications as read` }; + } + + async dismiss(userId, notificationId) { + const notification = await notificationsRepository.findByIdAndUser( + notificationId, + userId, + { dismissed_at: null } + ); + if (!notification) { + throw new NotFoundError('Notification not found'); + } + await notification.dismiss(); + return { message: 'Notification dismissed successfully' }; + } +} + +module.exports = new NotificationsService(); diff --git a/backend/modules/projects/controller.js b/backend/modules/projects/controller.js new file mode 100644 index 0000000..eac478a --- /dev/null +++ b/backend/modules/projects/controller.js @@ -0,0 +1,126 @@ +'use strict'; + +const projectsService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); +const { extractUidFromSlug } = require('../../utils/slug-utils'); +const { logError } = require('../../services/logService'); + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Projects controller - handles HTTP requests/responses. + */ +const projectsController = { + /** + * GET /api/projects + * List all projects for the current user. + */ + async list(req, res, next) { + try { + const userId = requireUserId(req); + const result = await projectsService.getAll(userId, req.query); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/project/:uidSlug + * Get a single project by UID. + */ + async getOne(req, res, next) { + try { + const uid = extractUidFromSlug(req.params.uidSlug); + const project = await projectsService.getByUid(uid); + res.json(project); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/project + * Create a new project. + */ + async create(req, res, next) { + try { + const userId = requireUserId(req); + const project = await projectsService.create(userId, req.body); + res.status(201).json(project); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/project/:uid + * Update a project. + */ + async update(req, res, next) { + try { + const userId = requireUserId(req); + const project = await projectsService.update( + userId, + req.params.uid, + req.body + ); + res.json(project); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/project/:uid + * Delete a project. + */ + async delete(req, res, next) { + try { + const userId = requireUserId(req); + const result = await projectsService.delete(userId, req.params.uid); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/upload/project-image + * Upload a project image. + */ + async uploadImage(req, res, next) { + try { + if (!req.file) { + return res + .status(400) + .json({ error: 'No image file provided' }); + } + const imageUrl = `/api/uploads/projects/${req.file.filename}`; + res.json({ imageUrl }); + } catch (error) { + logError('Error uploading image:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } + }, + + /** + * Get project UID if exists (for authorization middleware). + */ + async getProjectUidForAuth(req) { + const uid = extractUidFromSlug(req.params.uidSlug || req.params.uid); + return projectsService.getProjectUidIfExists(uid); + }, +}; + +module.exports = projectsController; diff --git a/backend/services/dueProjectService.js b/backend/modules/projects/dueProjectService.js similarity index 97% rename from backend/services/dueProjectService.js rename to backend/modules/projects/dueProjectService.js index 002adac..920b44f 100644 --- a/backend/services/dueProjectService.js +++ b/backend/modules/projects/dueProjectService.js @@ -1,10 +1,10 @@ -const { Project, Notification, User } = require('../models'); +const { Project, Notification, User } = require('../../models'); const { Op } = require('sequelize'); -const { logError } = require('./logService'); +const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, -} = require('../utils/notificationPreferences'); +} = require('../../utils/notificationPreferences'); /** * Service to check for due and overdue projects diff --git a/backend/modules/projects/index.js b/backend/modules/projects/index.js new file mode 100644 index 0000000..bdb120d --- /dev/null +++ b/backend/modules/projects/index.js @@ -0,0 +1,30 @@ +'use strict'; + +/** + * Projects Module + * + * This module handles all project-related functionality including: + * - CRUD operations for projects + * - Project image uploads + * - Tag management for projects + * - Area association + * - Share count tracking + * + * Usage: + * const projectsModule = require('./modules/projects'); + * app.use('/api', projectsModule.routes); + */ + +const routes = require('./routes'); +const projectsService = require('./service'); +const projectsRepository = require('./repository'); +const { validateUid, validateName, formatDate } = require('./validation'); + +module.exports = { + routes, + projectsService, + projectsRepository, + validateUid, + validateName, + formatDate, +}; diff --git a/backend/modules/projects/repository.js b/backend/modules/projects/repository.js new file mode 100644 index 0000000..0b6d27f --- /dev/null +++ b/backend/modules/projects/repository.js @@ -0,0 +1,256 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { + Project, + Task, + Tag, + Area, + Note, + User, + Permission, + sequelize, +} = require('../../models'); +const { Op } = require('sequelize'); + +class ProjectsRepository extends BaseRepository { + constructor() { + super(Project); + } + + /** + * Find all projects with filters and includes. + */ + async findAllWithFilters(whereClause) { + return this.model.findAll({ + where: whereClause, + include: [ + { + model: Task, + required: false, + attributes: ['id', 'status'], + where: { + parent_task_id: null, + recurring_parent_id: null, + }, + }, + { + model: Area, + required: false, + attributes: ['id', 'uid', 'name'], + }, + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + { + model: User, + required: false, + attributes: ['uid'], + }, + ], + order: [['name', 'ASC']], + }); + } + + /** + * Get share counts for multiple projects. + */ + async getShareCounts(projectUids) { + if (projectUids.length === 0) return {}; + + const shareCounts = await Permission.findAll({ + attributes: [ + 'resource_uid', + [sequelize.fn('COUNT', sequelize.col('id')), 'count'], + ], + where: { + resource_type: 'project', + resource_uid: { [Op.in]: projectUids }, + }, + group: ['resource_uid'], + raw: true, + }); + + const uidToCount = {}; + shareCounts.forEach((item) => { + uidToCount[item.resource_uid] = parseInt(item.count, 10); + }); + + return uidToCount; + } + + /** + * Find project by UID (simple). + */ + async findByUid(uid) { + return this.model.findOne({ + where: { uid }, + attributes: ['id', 'uid', 'user_id'], + }); + } + + /** + * Find project by UID with full includes. + */ + async findByUidWithIncludes(uid) { + return this.model.findOne({ + where: { uid }, + include: [ + { + model: Task, + required: false, + where: { + parent_task_id: null, + recurring_parent_id: null, + }, + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + required: false, + }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + required: false, + }, + ], + required: false, + }, + ], + }, + { + model: Note, + required: false, + attributes: [ + 'id', + 'uid', + 'title', + 'content', + 'created_at', + 'updated_at', + ], + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + }, + { + model: Area, + required: false, + attributes: ['id', 'uid', 'name'], + }, + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + }); + } + + /** + * Find project by UID with tags and area. + */ + async findByUidWithTagsAndArea(uid) { + return this.model.findOne({ + where: { uid }, + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + { + model: Area, + required: false, + attributes: ['id', 'uid', 'name'], + }, + ], + }); + } + + /** + * Get share count for a single project. + */ + async getShareCount(projectUid) { + return Permission.count({ + where: { + resource_type: 'project', + resource_uid: projectUid, + }, + }); + } + + /** + * Find area by UID. + */ + async findAreaByUid(uid) { + return Area.findOne({ + where: { uid }, + attributes: ['id'], + }); + } + + /** + * Delete project with orphaning tasks and notes. + */ + async deleteWithOrphaning(project, userId) { + await sequelize.transaction(async (transaction) => { + await sequelize.query('PRAGMA foreign_keys = OFF', { transaction }); + + try { + await Task.update( + { project_id: null }, + { + where: { project_id: project.id, user_id: userId }, + transaction, + } + ); + + await Note.update( + { project_id: null }, + { + where: { project_id: project.id, user_id: userId }, + transaction, + } + ); + + await project.destroy({ transaction }); + } finally { + await sequelize.query('PRAGMA foreign_keys = ON', { + transaction, + }); + } + }); + } + + /** + * Find existing tags by names for a user. + */ + async findTagsByNames(userId, tagNames) { + return Tag.findAll({ + where: { user_id: userId, name: tagNames }, + }); + } + + /** + * Create a tag. + */ + async createTag(name, userId) { + return Tag.create({ name, user_id: userId }); + } +} + +module.exports = new ProjectsRepository(); diff --git a/backend/modules/projects/routes.js b/backend/modules/projects/routes.js new file mode 100644 index 0000000..792ca8a --- /dev/null +++ b/backend/modules/projects/routes.js @@ -0,0 +1,102 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { getConfig } = require('../../config/config'); +const config = getConfig(); +const router = express.Router(); +const projectsController = require('./controller'); +const { hasAccess } = require('../../middleware/authorize'); +const { requireAuth } = require('../../middleware/auth'); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadDir = path.join(config.uploadPath, '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: 10 * 1024 * 1024, // 10MB 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!')); + } + }, +}); + +// All routes require authentication (handled by app.js middleware) + +// Upload project image +router.post( + '/upload/project-image', + requireAuth, + upload.single('image'), + projectsController.uploadImage +); + +// List all projects +router.get('/projects', projectsController.list); + +// Get a single project (requires read access) +router.get( + '/project/:uidSlug', + hasAccess( + 'ro', + 'project', + (req) => projectsController.getProjectUidForAuth(req), + { notFoundMessage: 'Project not found' } + ), + projectsController.getOne +); + +// Create a new project +router.post('/project', projectsController.create); + +// Update a project (requires write access) +router.patch( + '/project/:uid', + hasAccess( + 'rw', + 'project', + (req) => projectsController.getProjectUidForAuth(req), + { notFoundMessage: 'Project not found.' } + ), + projectsController.update +); + +// Delete a project (requires write access) +router.delete( + '/project/:uid', + requireAuth, + hasAccess( + 'rw', + 'project', + (req) => projectsController.getProjectUidForAuth(req), + { notFoundMessage: 'Project not found.' } + ), + projectsController.delete +); + +module.exports = router; diff --git a/backend/modules/projects/service.js b/backend/modules/projects/service.js new file mode 100644 index 0000000..6ae132e --- /dev/null +++ b/backend/modules/projects/service.js @@ -0,0 +1,364 @@ +'use strict'; + +const { Op } = require('sequelize'); +const projectsRepository = require('./repository'); +const { validateUid, validateName, formatDate } = require('./validation'); +const { NotFoundError, ValidationError } = require('../../shared/errors'); +const { validateTagName } = require('../tags/tagsService'); +const permissionsService = require('../../services/permissionsService'); +const { sortTags } = require('../tasks/core/serializers'); +const { uid: generateUid } = require('../../utils/uid'); +const { extractUidFromSlug } = require('../../utils/slug-utils'); +const { logError } = require('../../services/logService'); + +/** + * Update project tags. + */ +async function updateProjectTags(project, tagsData, userId) { + if (!tagsData) return; + + const validTagNames = []; + const invalidTags = []; + + for (const tag of tagsData) { + const validation = validateTagName(tag.name); + if (validation.valid) { + if (!validTagNames.includes(validation.name)) { + validTagNames.push(validation.name); + } + } else { + invalidTags.push({ name: tag.name, error: validation.error }); + } + } + + if (invalidTags.length > 0) { + throw new ValidationError( + `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` + ); + } + + if (validTagNames.length === 0) { + await project.setTags([]); + return; + } + + const existingTags = await projectsRepository.findTagsByNames( + userId, + validTagNames + ); + const existingTagNames = existingTags.map((tag) => tag.name); + const newTagNames = validTagNames.filter( + (name) => !existingTagNames.includes(name) + ); + + const createdTags = await Promise.all( + newTagNames.map((name) => projectsRepository.createTag(name, userId)) + ); + + await project.setTags([...existingTags, ...createdTags]); +} + +/** + * Calculate task status counts. + */ +function calculateTaskStatus(tasks) { + const taskList = tasks || []; + return { + total: taskList.length, + done: taskList.filter((t) => t.status === 2).length, + in_progress: taskList.filter((t) => t.status === 1).length, + not_started: taskList.filter((t) => t.status === 0).length, + }; +} + +class ProjectsService { + /** + * Get all projects for a user with filters. + */ + async getAll(userId, query) { + const { + status, + state, + active, + pin_to_sidebar, + area_id, + area, + grouped, + } = query; + const statusFilter = status || state; + + let whereClause = await permissionsService.ownershipOrPermissionWhere( + 'project', + userId + ); + + if (statusFilter && statusFilter !== 'all') { + if (Array.isArray(statusFilter)) { + whereClause.status = { [Op.in]: statusFilter }; + } else { + whereClause.status = statusFilter; + } + } + + if (active === 'true') { + whereClause.status = { + [Op.in]: ['planned', 'in_progress', 'waiting'], + }; + } else if (active === 'false') { + whereClause.status = { [Op.in]: ['not_started', 'done'] }; + } + + if (pin_to_sidebar === 'true') { + whereClause.pin_to_sidebar = true; + } else if (pin_to_sidebar === 'false') { + whereClause.pin_to_sidebar = false; + } + + if (area && area !== '') { + const uid = extractUidFromSlug(area); + if (uid) { + const areaRecord = await projectsRepository.findAreaByUid(uid); + if (areaRecord) { + whereClause = { + [Op.and]: [whereClause, { area_id: areaRecord.id }], + }; + } + } + } else if (area_id && area_id !== '') { + whereClause = { [Op.and]: [whereClause, { area_id }] }; + } + + const projects = + await projectsRepository.findAllWithFilters(whereClause); + + const projectUids = projects.map((p) => p.uid).filter(Boolean); + const shareCountMap = + await projectsRepository.getShareCounts(projectUids); + + const enhancedProjects = projects.map((project) => { + const taskStatus = calculateTaskStatus(project.Tasks); + const projectJson = project.toJSON(); + const shareCount = shareCountMap[project.uid] || 0; + + return { + ...projectJson, + tags: sortTags(projectJson.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, + user_uid: projectJson.User?.uid, + share_count: shareCount, + is_shared: shareCount > 0, + }; + }); + + 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); + }); + return groupedProjects; + } + + return { projects: enhancedProjects }; + } + + /** + * Get project by UID. + */ + async getByUid(uid) { + const validatedUid = validateUid(uid); + const project = + await projectsRepository.findByUidWithIncludes(validatedUid); + + if (!project) { + throw new NotFoundError('Project not found'); + } + + const projectJson = project.toJSON(); + + const normalizedTasks = projectJson.Tasks + ? projectJson.Tasks.map((task) => { + const normalizedTask = { + ...task, + tags: sortTags(task.Tags), + subtasks: (task.Subtasks || []).map((subtask) => ({ + ...subtask, + tags: sortTags(subtask.Tags), + })), + due_date: task.due_date + ? typeof task.due_date === 'string' + ? task.due_date.split('T')[0] + : task.due_date.toISOString().split('T')[0] + : null, + }; + delete normalizedTask.Tags; + delete normalizedTask.Subtasks; + return normalizedTask; + }) + : []; + + const normalizedNotes = projectJson.Notes + ? projectJson.Notes.map((note) => { + const normalizedNote = { ...note, tags: sortTags(note.Tags) }; + delete normalizedNote.Tags; + return normalizedNote; + }) + : []; + + const shareCount = project.uid + ? await projectsRepository.getShareCount(project.uid) + : 0; + + return { + ...projectJson, + tags: sortTags(projectJson.Tags), + Tasks: normalizedTasks, + Notes: normalizedNotes, + due_date_at: formatDate(project.due_date_at), + user_id: project.user_id, + share_count: shareCount, + is_shared: shareCount > 0, + }; + } + + /** + * Check if project exists and return UID (for authorization). + */ + async getProjectUidIfExists(uidOrSlug) { + const uid = extractUidFromSlug(uidOrSlug); + const project = await projectsRepository.findByUid(uid); + return project ? project.uid : null; + } + + /** + * Create a new project. + */ + async create(userId, data) { + const { + name, + description, + area_id, + priority, + due_date_at, + image_url, + status, + state, + tags, + Tags, + } = data; + + const validatedName = validateName(name); + const tagsData = tags || Tags; + const projectUid = generateUid(); + + const projectData = { + uid: projectUid, + name: validatedName, + description: description || '', + area_id: area_id || null, + pin_to_sidebar: false, + priority: priority || null, + due_date_at: due_date_at || null, + image_url: image_url || null, + status: status || state || 'not_started', + user_id: userId, + }; + + const project = await projectsRepository.create(projectData); + + try { + await updateProjectTags(project, tagsData, userId); + } catch (tagError) { + logError( + 'Tag update failed, but project created successfully:', + tagError.message + ); + } + + return { + ...project.toJSON(), + uid: projectUid, + tags: [], + due_date_at: formatDate(project.due_date_at), + }; + } + + /** + * Update a project. + */ + async update(userId, uid, data) { + const validatedUid = validateUid(uid); + const project = await projectsRepository.findOne({ uid: validatedUid }); + + if (!project) { + throw new NotFoundError('Project not found.'); + } + + const { + name, + description, + area_id, + pin_to_sidebar, + priority, + due_date_at, + image_url, + status, + state, + tags, + Tags, + } = data; + + 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 (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; + if (status !== undefined) updateData.status = status; + else if (state !== undefined) updateData.status = state; + + await projectsRepository.update(project, updateData); + await updateProjectTags(project, tagsData, userId); + + const projectWithAssociations = + await projectsRepository.findByUidWithTagsAndArea(validatedUid); + const projectJson = projectWithAssociations.toJSON(); + + return { + ...projectJson, + tags: sortTags(projectJson.Tags), + due_date_at: formatDate(projectWithAssociations.due_date_at), + }; + } + + /** + * Delete a project. + */ + async delete(userId, uid) { + const validatedUid = validateUid(uid); + const project = await projectsRepository.findOne({ uid: validatedUid }); + + if (!project) { + throw new NotFoundError('Project not found.'); + } + + await projectsRepository.deleteWithOrphaning(project, userId); + return { message: 'Project successfully deleted' }; + } +} + +module.exports = new ProjectsService(); +module.exports.updateProjectTags = updateProjectTags; diff --git a/backend/modules/projects/validation.js b/backend/modules/projects/validation.js new file mode 100644 index 0000000..a3eec4f --- /dev/null +++ b/backend/modules/projects/validation.js @@ -0,0 +1,50 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); +const { isValidUid, extractUidFromSlug } = require('../../utils/slug-utils'); + +/** + * Validate project UID. + */ +function validateUid(uidOrSlug) { + if (!uidOrSlug || typeof uidOrSlug !== 'string') { + throw new ValidationError('Project UID is required.'); + } + + const uid = extractUidFromSlug(uidOrSlug); + if (!uid || !isValidUid(uid)) { + throw new ValidationError('Invalid project UID format.'); + } + + return uid; +} + +/** + * Validate project name. + */ +function validateName(name) { + if (!name || typeof name !== 'string' || !name.trim()) { + throw new ValidationError('Project name is required'); + } + return name.trim(); +} + +/** + * Safely format dates. + */ +function 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; + } +} + +module.exports = { + validateUid, + validateName, + formatDate, +}; diff --git a/backend/modules/quotes/controller.js b/backend/modules/quotes/controller.js new file mode 100644 index 0000000..7c6e6ba --- /dev/null +++ b/backend/modules/quotes/controller.js @@ -0,0 +1,28 @@ +'use strict'; + +const quotesService = require('./quotesService'); + +const quotesController = { + async getRandom(req, res, next) { + try { + const quote = quotesService.getRandomQuote(); + res.json({ quote }); + } catch (error) { + next(error); + } + }, + + async getAll(req, res, next) { + try { + const quotes = quotesService.getAllQuotes(); + res.json({ + quotes, + count: quotesService.getQuotesCount(), + }); + } catch (error) { + next(error); + } + }, +}; + +module.exports = quotesController; diff --git a/backend/modules/quotes/index.js b/backend/modules/quotes/index.js new file mode 100644 index 0000000..d893df1 --- /dev/null +++ b/backend/modules/quotes/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const routes = require('./routes'); + +module.exports = { routes }; diff --git a/backend/services/quotesService.js b/backend/modules/quotes/quotesService.js similarity index 100% rename from backend/services/quotesService.js rename to backend/modules/quotes/quotesService.js diff --git a/backend/modules/quotes/routes.js b/backend/modules/quotes/routes.js new file mode 100644 index 0000000..965d392 --- /dev/null +++ b/backend/modules/quotes/routes.js @@ -0,0 +1,10 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const quotesController = require('./controller'); + +router.get('/quotes/random', quotesController.getRandom); +router.get('/quotes', quotesController.getAll); + +module.exports = router; diff --git a/backend/modules/search/controller.js b/backend/modules/search/controller.js new file mode 100644 index 0000000..40f2e02 --- /dev/null +++ b/backend/modules/search/controller.js @@ -0,0 +1,29 @@ +'use strict'; + +const searchService = require('./service'); + +/** + * Search controller - handles HTTP requests/responses. + */ +const searchController = { + /** + * GET /api/search + * Universal search endpoint. + */ + async search(req, res, next) { + try { + const userId = req.currentUser?.id; + const timezone = req.currentUser?.timezone || 'UTC'; + const result = await searchService.search( + userId, + req.query, + timezone + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = searchController; diff --git a/backend/modules/search/index.js b/backend/modules/search/index.js new file mode 100644 index 0000000..38dc3e8 --- /dev/null +++ b/backend/modules/search/index.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Search Module + * + * This module handles universal search across all entity types: + * - Tasks (with filters for priority, due date, recurring, etc.) + * - Projects + * - Areas + * - Notes + * - Tags + * + * Usage: + * const searchModule = require('./modules/search'); + * app.use('/api/search', searchModule.routes); + */ + +const routes = require('./routes'); +const searchService = require('./service'); +const searchRepository = require('./repository'); +const { parseSearchParams, priorityToInt } = require('./validation'); + +module.exports = { + routes, + searchService, + searchRepository, + parseSearchParams, + priorityToInt, +}; diff --git a/backend/modules/search/repository.js b/backend/modules/search/repository.js new file mode 100644 index 0000000..e42fca3 --- /dev/null +++ b/backend/modules/search/repository.js @@ -0,0 +1,134 @@ +'use strict'; + +const { Task, Tag, Project, Area, Note, sequelize } = require('../../models'); +const { Op } = require('sequelize'); + +class SearchRepository { + /** + * Find tag IDs by names for a user. + */ + async findTagIdsByNames(userId, tagNames) { + if (tagNames.length === 0) return []; + + const tags = await Tag.findAll({ + where: { + user_id: userId, + name: { [Op.in]: tagNames }, + }, + attributes: ['id'], + }); + return tags.map((tag) => tag.id); + } + + /** + * Count tasks matching conditions. + */ + async countTasks(conditions, include) { + return Task.count({ + where: conditions, + include, + distinct: true, + }); + } + + /** + * Find tasks matching conditions. + */ + async findTasks(conditions, include, limit, offset) { + return Task.findAll({ + where: conditions, + include, + limit, + offset, + order: [['updated_at', 'DESC']], + }); + } + + /** + * Count projects matching conditions. + */ + async countProjects(conditions, include) { + return Project.count({ + where: conditions, + include: include?.length > 0 ? include : undefined, + distinct: true, + }); + } + + /** + * Find projects matching conditions. + */ + async findProjects(conditions, include, limit, offset) { + return Project.findAll({ + where: conditions, + include: include?.length > 0 ? include : undefined, + limit, + offset, + order: [['updated_at', 'DESC']], + }); + } + + /** + * Count areas matching conditions. + */ + async countAreas(conditions) { + return Area.count({ where: conditions }); + } + + /** + * Find areas matching conditions. + */ + async findAreas(conditions, limit, offset) { + return Area.findAll({ + where: conditions, + limit, + offset, + order: [['updated_at', 'DESC']], + }); + } + + /** + * Count notes matching conditions. + */ + async countNotes(conditions, include) { + return Note.count({ + where: conditions, + include: include?.length > 0 ? include : undefined, + distinct: true, + }); + } + + /** + * Find notes matching conditions. + */ + async findNotes(conditions, include, limit, offset) { + return Note.findAll({ + where: conditions, + include: include?.length > 0 ? include : undefined, + limit, + offset, + order: [['updated_at', 'DESC']], + }); + } + + /** + * Count tags matching conditions. + */ + async countTags(conditions) { + return Tag.count({ where: conditions }); + } + + /** + * Find tags matching conditions. + */ + async findTags(conditions, limit, offset) { + return Tag.findAll({ + where: conditions, + limit, + offset, + order: [['name', 'ASC']], + }); + } +} + +module.exports = new SearchRepository(); diff --git a/backend/modules/search/routes.js b/backend/modules/search/routes.js new file mode 100644 index 0000000..cda6a72 --- /dev/null +++ b/backend/modules/search/routes.js @@ -0,0 +1,9 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const searchController = require('./controller'); + +router.get('/search', searchController.search); + +module.exports = router; diff --git a/backend/modules/search/service.js b/backend/modules/search/service.js new file mode 100644 index 0000000..24ceaf1 --- /dev/null +++ b/backend/modules/search/service.js @@ -0,0 +1,581 @@ +'use strict'; + +const { Op } = require('sequelize'); +const moment = require('moment-timezone'); +const { Task, Tag, Project, sequelize } = require('../../models'); +const searchRepository = require('./repository'); +const { parseSearchParams, priorityToInt } = require('./validation'); +const { serializeTasks } = require('../tasks/core/serializers'); +const { UnauthorizedError } = require('../../shared/errors'); + +class SearchService { + /** + * Build date range condition for due/defer filters. + */ + buildDateCondition(filterValue, startOfToday, fieldName) { + if (!filterValue) return null; + + let startDate, endDate; + + switch (filterValue) { + case 'today': + startDate = startOfToday.clone(); + endDate = startOfToday.clone().endOf('day'); + break; + case 'tomorrow': + startDate = startOfToday.clone().add(1, 'day'); + endDate = startOfToday.clone().add(1, 'day').endOf('day'); + break; + case 'next_week': + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(7, 'days').endOf('day'); + break; + case 'next_month': + startDate = startOfToday.clone(); + endDate = startOfToday.clone().add(1, 'month').endOf('day'); + break; + default: + return null; + } + + return { + [fieldName]: { + [Op.between]: [startDate.toDate(), endDate.toDate()], + }, + }; + } + + /** + * Build task search conditions. + */ + buildTaskConditions( + userId, + params, + dueDateCondition, + deferDateCondition, + nowDate + ) { + const { searchQuery, priority, recurring, extras, excludeSubtasks } = + params; + + const conditions = { user_id: userId }; + const extraConditions = []; + + if (excludeSubtasks) { + conditions.parent_task_id = null; + conditions.recurring_parent_id = null; + } + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + conditions[Op.or] = [ + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Task.name')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Task.note')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + ]; + } + + if (priority) { + const priorityInt = priorityToInt(priority); + if (priorityInt !== null) { + conditions.priority = priorityInt; + } + } + + if (dueDateCondition) { + extraConditions.push(dueDateCondition); + } + + if (deferDateCondition) { + extraConditions.push(deferDateCondition); + } + + if (recurring) { + switch (recurring) { + case 'recurring': + conditions.recurrence_type = { [Op.ne]: 'none' }; + conditions.recurring_parent_id = null; + break; + case 'non_recurring': + conditions[Op.or] = [ + { recurrence_type: 'none' }, + { recurrence_type: null }, + ]; + conditions.recurring_parent_id = null; + break; + case 'instances': + conditions.recurring_parent_id = { [Op.ne]: null }; + break; + } + } + + if (extras.has('recurring')) { + extraConditions.push({ + [Op.or]: [ + { recurrence_type: { [Op.ne]: 'none' } }, + { recurring_parent_id: { [Op.ne]: null } }, + ], + }); + } + + if (extras.has('overdue')) { + extraConditions.push({ due_date: { [Op.lt]: nowDate } }); + extraConditions.push({ completed_at: null }); + } + + if (extras.has('has_content')) { + extraConditions.push( + sequelize.where( + sequelize.fn( + 'LENGTH', + sequelize.fn('TRIM', sequelize.col('Task.note')) + ), + { [Op.gt]: 0 } + ) + ); + } + + if (extras.has('deferred')) { + extraConditions.push({ defer_until: { [Op.gt]: nowDate } }); + } + + if (extras.has('assigned_to_project')) { + extraConditions.push({ project_id: { [Op.ne]: null } }); + } + + if (extraConditions.length > 0) { + conditions[Op.and] = extraConditions; + } + + return conditions; + } + + /** + * Build task include config. + */ + buildTaskInclude(tagIds, extras) { + const include = [ + { model: Project, attributes: ['id', 'uid', 'name'] }, + { + model: Task, + as: 'Subtasks', + include: [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + }, + ], + }, + ]; + + const requireTags = tagIds.length > 0 || extras.has('has_tags'); + const tagInclude = { + model: Tag, + through: { attributes: [] }, + attributes: ['id', 'name', 'uid'], + required: requireTags, + }; + + if (tagIds.length > 0) { + tagInclude.where = { id: { [Op.in]: tagIds } }; + } + + include.push(tagInclude); + return { include, tagInclude: requireTags ? tagInclude : undefined }; + } + + /** + * Search tasks. + */ + async searchTasks( + userId, + params, + tagIds, + dueDateCondition, + deferDateCondition, + nowDate, + timezone + ) { + const conditions = this.buildTaskConditions( + userId, + params, + dueDateCondition, + deferDateCondition, + nowDate + ); + const { include, tagInclude } = this.buildTaskInclude( + tagIds, + params.extras + ); + + let count = 0; + if (params.hasPagination) { + count = await searchRepository.countTasks( + conditions, + tagInclude ? [tagInclude] : undefined + ); + } + + const tasks = await searchRepository.findTasks( + conditions, + include, + params.limit, + params.offset + ); + + const serializedTasks = await serializeTasks(tasks, timezone); + + return { + count, + results: serializedTasks.map((task) => ({ + type: 'Task', + ...task, + description: task.note, + })), + }; + } + + /** + * Search projects. + */ + async searchProjects(userId, params, tagIds, dueDateCondition) { + const { searchQuery, priority, extras, hasPagination, limit, offset } = + params; + + const conditions = { user_id: userId }; + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + conditions[Op.or] = [ + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Project.name')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Project.description')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + ]; + } + + if (priority) { + conditions.priority = priority; + } + + if (dueDateCondition) { + conditions.due_date_at = dueDateCondition.due_date; + } + + const requireTags = tagIds.length > 0 || extras.has('has_tags'); + const include = []; + + if (requireTags) { + const tagInclude = { + model: Tag, + through: { attributes: [] }, + attributes: [], + required: true, + }; + if (tagIds.length > 0) { + tagInclude.where = { id: { [Op.in]: tagIds } }; + } + include.push(tagInclude); + } + + let count = 0; + if (hasPagination) { + count = await searchRepository.countProjects(conditions, include); + } + + const projects = await searchRepository.findProjects( + conditions, + include, + limit, + offset + ); + + return { + count, + results: projects.map((project) => ({ + type: 'Project', + id: project.id, + uid: project.uid, + name: project.name, + description: project.description, + priority: project.priority, + status: project.status, + })), + }; + } + + /** + * Search areas. + */ + async searchAreas(userId, params) { + const { searchQuery, hasPagination, limit, offset } = params; + + const conditions = { user_id: userId }; + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + conditions[Op.or] = [ + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Area.name')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Area.description')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + ]; + } + + let count = 0; + if (hasPagination) { + count = await searchRepository.countAreas(conditions); + } + + const areas = await searchRepository.findAreas( + conditions, + limit, + offset + ); + + return { + count, + results: areas.map((area) => ({ + type: 'Area', + id: area.id, + uid: area.uid, + name: area.name, + description: area.description, + })), + }; + } + + /** + * Search notes. + */ + async searchNotes(userId, params, tagIds) { + const { searchQuery, hasPagination, limit, offset } = params; + + const conditions = { user_id: userId }; + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + conditions[Op.or] = [ + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Note.title')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Note.content')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + ]; + } + + const include = []; + if (tagIds.length > 0) { + include.push({ + model: Tag, + where: { id: { [Op.in]: tagIds } }, + through: { attributes: [] }, + attributes: [], + required: true, + }); + } + + let count = 0; + if (hasPagination) { + count = await searchRepository.countNotes(conditions, include); + } + + const notes = await searchRepository.findNotes( + conditions, + include, + limit, + offset + ); + + return { + count, + results: notes.map((note) => ({ + type: 'Note', + id: note.id, + uid: note.uid, + name: note.title, + title: note.title, + description: note.content ? note.content.substring(0, 100) : '', + })), + }; + } + + /** + * Search tags. + */ + async searchTags(userId, params) { + const { searchQuery, hasPagination, limit, offset } = params; + + const conditions = { user_id: userId }; + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + conditions[Op.and] = [ + sequelize.where( + sequelize.fn('LOWER', sequelize.col('Tag.name')), + { + [Op.like]: `%${lowerQuery}%`, + } + ), + ]; + } + + let count = 0; + if (hasPagination) { + count = await searchRepository.countTags(conditions); + } + + const tags = await searchRepository.findTags(conditions, limit, offset); + + return { + count, + results: tags.map((tag) => ({ + type: 'Tag', + id: tag.id, + uid: tag.uid, + name: tag.name, + })), + }; + } + + /** + * Universal search across all entity types. + */ + async search(userId, query, timezone = 'UTC') { + if (!userId) { + throw new UnauthorizedError('Unauthorized'); + } + + const params = parseSearchParams(query); + const { + filterTypes, + tagNames, + due, + defer, + hasPagination, + limit, + offset, + } = params; + + // Find tag IDs if filtering by tags + const tagIds = await searchRepository.findTagIdsByNames( + userId, + tagNames + ); + if (tagNames.length > 0 && tagIds.length === 0) { + return { results: [] }; + } + + // Calculate date conditions + const nowMoment = moment().tz(timezone); + const startOfToday = nowMoment.clone().startOf('day'); + const nowDate = nowMoment.toDate(); + + const dueDateCondition = this.buildDateCondition( + due, + startOfToday, + 'due_date' + ); + const deferDateCondition = this.buildDateCondition( + defer, + startOfToday, + 'defer_until' + ); + + const results = []; + let totalCount = 0; + + // Search each entity type + if (filterTypes.includes('Task')) { + const taskResults = await this.searchTasks( + userId, + params, + tagIds, + dueDateCondition, + deferDateCondition, + nowDate, + timezone + ); + results.push(...taskResults.results); + totalCount += taskResults.count; + } + + if (filterTypes.includes('Project')) { + const projectResults = await this.searchProjects( + userId, + params, + tagIds, + dueDateCondition + ); + results.push(...projectResults.results); + totalCount += projectResults.count; + } + + if (filterTypes.includes('Area')) { + const areaResults = await this.searchAreas(userId, params); + results.push(...areaResults.results); + totalCount += areaResults.count; + } + + if (filterTypes.includes('Note')) { + const noteResults = await this.searchNotes(userId, params, tagIds); + results.push(...noteResults.results); + totalCount += noteResults.count; + } + + if (filterTypes.includes('Tag')) { + const tagResults = await this.searchTags(userId, params); + results.push(...tagResults.results); + totalCount += tagResults.count; + } + + if (hasPagination) { + return { + results, + pagination: { + total: totalCount, + limit, + offset, + hasMore: offset + results.length < totalCount, + }, + }; + } + + return { results }; + } +} + +module.exports = new SearchService(); diff --git a/backend/modules/search/validation.js b/backend/modules/search/validation.js new file mode 100644 index 0000000..e3c347a --- /dev/null +++ b/backend/modules/search/validation.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Parse and validate search query parameters. + */ +function parseSearchParams(query) { + const { + q, + filters, + priority, + due, + defer, + tags: tagsParam, + recurring, + extras: extrasParam, + limit: limitParam, + offset: offsetParam, + excludeSubtasks, + } = query; + + const searchQuery = q ? q.trim() : ''; + + const filterTypes = filters + ? filters.split(',').map((f) => f.trim()) + : ['Task', 'Project', 'Area', 'Note', 'Tag']; + + const tagNames = tagsParam ? tagsParam.split(',').map((t) => t.trim()) : []; + + const extras = + extrasParam && typeof extrasParam === 'string' + ? extrasParam + .split(',') + .map((extra) => extra.trim()) + .filter(Boolean) + : []; + + const hasPagination = limitParam !== undefined || offsetParam !== undefined; + const limit = hasPagination ? parseInt(limitParam, 10) || 20 : 20; + const offset = hasPagination ? parseInt(offsetParam, 10) || 0 : 0; + + return { + searchQuery, + filterTypes, + priority, + due, + defer, + tagNames, + recurring, + extras: new Set(extras), + hasPagination, + limit, + offset, + excludeSubtasks: excludeSubtasks === 'true', + }; +} + +/** + * Convert priority string to integer. + */ +function priorityToInt(priorityStr) { + const priorityMap = { + low: 0, + medium: 1, + high: 2, + }; + return priorityMap[priorityStr] !== undefined + ? priorityMap[priorityStr] + : null; +} + +module.exports = { + parseSearchParams, + priorityToInt, +}; diff --git a/backend/modules/shares/controller.js b/backend/modules/shares/controller.js new file mode 100644 index 0000000..825f619 --- /dev/null +++ b/backend/modules/shares/controller.js @@ -0,0 +1,86 @@ +'use strict'; + +const sharesService = require('./service'); +const { logError } = require('../../services/logService'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +const sharesController = { + async create(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + await sharesService.createShare(userId, req.body); + res.status(204).end(); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.statusCode === 403) { + return res.status(403).json({ error: error.message }); + } + if (error.statusCode === 404) { + return res.status(404).json({ error: error.message }); + } + logError('Error sharing resource:', error); + res.status(400).json({ error: 'Unable to share resource' }); + } + }, + + async delete(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + await sharesService.deleteShare(userId, req.body); + res.status(204).end(); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.statusCode === 403) { + return res.status(403).json({ error: error.message }); + } + logError('Error revoking share:', error); + res.status(400).json({ error: 'Unable to revoke share' }); + } + }, + + async getAll(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + + const { resource_type, resource_uid } = req.query; + const result = await sharesService.getShares( + userId, + resource_type, + resource_uid + ); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.statusCode === 403) { + return res.status(403).json({ error: error.message }); + } + logError('Error listing shares:', error); + res.status(400).json({ error: 'Unable to list shares' }); + } + }, +}; + +module.exports = sharesController; diff --git a/backend/modules/shares/index.js b/backend/modules/shares/index.js new file mode 100644 index 0000000..7b0ffff --- /dev/null +++ b/backend/modules/shares/index.js @@ -0,0 +1,7 @@ +'use strict'; + +const routes = require('./routes'); +const sharesService = require('./service'); +const sharesRepository = require('./repository'); + +module.exports = { routes, sharesService, sharesRepository }; diff --git a/backend/modules/shares/repository.js b/backend/modules/shares/repository.js new file mode 100644 index 0000000..721c806 --- /dev/null +++ b/backend/modules/shares/repository.js @@ -0,0 +1,61 @@ +'use strict'; + +const { User, Permission, Project, Task, Note } = require('../../models'); + +class SharesRepository { + async findResourceOwner(resourceType, resourceUid) { + let resource = null; + + if (resourceType === 'project') { + resource = await Project.findOne({ + where: { uid: resourceUid }, + attributes: ['user_id'], + raw: true, + }); + } else if (resourceType === 'task') { + resource = await Task.findOne({ + where: { uid: resourceUid }, + attributes: ['user_id'], + raw: true, + }); + } else if (resourceType === 'note') { + resource = await Note.findOne({ + where: { uid: resourceUid }, + attributes: ['user_id'], + raw: true, + }); + } + + return resource; + } + + async findUserByEmail(email) { + return User.findOne({ where: { email } }); + } + + async findUserById(id, attributes = ['id', 'email', 'avatar_image']) { + return User.findByPk(id, { attributes }); + } + + async findUsersByIds(ids) { + return User.findAll({ + where: { id: ids }, + attributes: ['id', 'email', 'avatar_image'], + raw: true, + }); + } + + async findPermissions(resourceType, resourceUid) { + return Permission.findAll({ + where: { + resource_type: resourceType, + resource_uid: resourceUid, + propagation: 'direct', + }, + attributes: ['user_id', 'access_level', 'created_at'], + raw: true, + }); + } +} + +module.exports = new SharesRepository(); diff --git a/backend/modules/shares/routes.js b/backend/modules/shares/routes.js new file mode 100644 index 0000000..8dd74c6 --- /dev/null +++ b/backend/modules/shares/routes.js @@ -0,0 +1,11 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const sharesController = require('./controller'); + +router.post('/shares', sharesController.create); +router.delete('/shares', sharesController.delete); +router.get('/shares', sharesController.getAll); + +module.exports = router; diff --git a/backend/modules/shares/service.js b/backend/modules/shares/service.js new file mode 100644 index 0000000..1fbb1b4 --- /dev/null +++ b/backend/modules/shares/service.js @@ -0,0 +1,188 @@ +'use strict'; + +const sharesRepository = require('./repository'); +const { execAction } = require('../../services/execAction'); +const { isAdmin } = require('../../services/rolesService'); +const { + ValidationError, + NotFoundError, + ForbiddenError, +} = require('../../shared/errors'); + +class SharesService { + async isResourceOwner(userId, resourceType, resourceUid) { + const resource = await sharesRepository.findResourceOwner( + resourceType, + resourceUid + ); + return resource && resource.user_id === userId; + } + + async createShare(userId, data) { + const { resource_type, resource_uid, target_user_email, access_level } = + data; + + if ( + !resource_type || + !resource_uid || + !target_user_email || + !access_level + ) { + throw new ValidationError('Missing parameters'); + } + + // Only owner (or admin) can grant shares + const userIsAdmin = await isAdmin(userId); + const userIsOwner = await this.isResourceOwner( + userId, + resource_type, + resource_uid + ); + if (!userIsAdmin && !userIsOwner) { + throw new ForbiddenError('Forbidden'); + } + + const target = + await sharesRepository.findUserByEmail(target_user_email); + if (!target) { + throw new NotFoundError('Target user not found'); + } + + // Get resource to check owner + const resource = await sharesRepository.findResourceOwner( + resource_type, + resource_uid + ); + if (!resource) { + throw new NotFoundError('Resource not found'); + } + + // Prevent sharing with the owner (owner already has full access) + if (resource.user_id === target.id) { + throw new ValidationError( + 'Cannot grant permissions to the owner. Owner already has full access.' + ); + } + + await execAction({ + verb: 'share_grant', + actorUserId: userId, + targetUserId: target.id, + resourceType: resource_type, + resourceUid: resource_uid, + accessLevel: access_level, + }); + + return null; // 204 No Content + } + + async deleteShare(userId, data) { + const { resource_type, resource_uid, target_user_id } = data; + + if (!resource_type || !resource_uid || !target_user_id) { + throw new ValidationError('Missing parameters'); + } + + // Only owner (or admin) can revoke shares + const userIsAdmin = await isAdmin(userId); + const userIsOwner = await this.isResourceOwner( + userId, + resource_type, + resource_uid + ); + if (!userIsAdmin && !userIsOwner) { + throw new ForbiddenError('Forbidden'); + } + + // Prevent revoking permissions from the owner + const resource = await sharesRepository.findResourceOwner( + resource_type, + resource_uid + ); + if (resource && resource.user_id === Number(target_user_id)) { + throw new ValidationError( + 'Cannot revoke permissions from the owner.' + ); + } + + await execAction({ + verb: 'share_revoke', + actorUserId: userId, + targetUserId: Number(target_user_id), + resourceType: resource_type, + resourceUid: resource_uid, + }); + + return null; // 204 No Content + } + + async getShares(userId, resourceType, resourceUid) { + if (!resourceType || !resourceUid) { + throw new ValidationError('Missing parameters'); + } + + // Only owner (or admin) can view shares + const userIsAdmin = await isAdmin(userId); + const userIsOwner = await this.isResourceOwner( + userId, + resourceType, + resourceUid + ); + if (!userIsAdmin && !userIsOwner) { + throw new ForbiddenError('Forbidden'); + } + + // Get resource owner information + let ownerInfo = null; + const resource = await sharesRepository.findResourceOwner( + resourceType, + resourceUid + ); + + if (resource) { + const owner = await sharesRepository.findUserById(resource.user_id); + if (owner) { + ownerInfo = { + user_id: owner.id, + access_level: 'owner', + created_at: null, + email: owner.email, + avatar_image: owner.avatar_image, + is_owner: true, + }; + } + } + + const rows = await sharesRepository.findPermissions( + resourceType, + resourceUid + ); + + // Attach emails and avatar images for display + const userIds = Array.from(new Set(rows.map((r) => r.user_id))).filter( + Boolean + ); + let usersById = {}; + if (userIds.length) { + const users = await sharesRepository.findUsersByIds(userIds); + usersById = users.reduce((acc, u) => { + acc[u.id] = { email: u.email, avatar_image: u.avatar_image }; + return acc; + }, {}); + } + + const withEmails = rows.map((r) => ({ + ...r, + email: usersById[r.user_id]?.email || null, + avatar_image: usersById[r.user_id]?.avatar_image || null, + is_owner: false, + })); + + // Prepend owner to the list + const allShares = ownerInfo ? [ownerInfo, ...withEmails] : withEmails; + + return { shares: allShares }; + } +} + +module.exports = new SharesService(); diff --git a/backend/modules/tags/controller.js b/backend/modules/tags/controller.js new file mode 100644 index 0000000..bed96fa --- /dev/null +++ b/backend/modules/tags/controller.js @@ -0,0 +1,91 @@ +'use strict'; + +const tagsService = require('./service'); + +/** + * Tags controller - handles HTTP requests/responses. + * Business logic is delegated to the service layer. + */ +const tagsController = { + /** + * GET /api/tags + * List all tags for the current user. + */ + async list(req, res, next) { + try { + const tags = await tagsService.getAllForUser(req.currentUser.id); + res.json(tags); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/tag?uid=xxx or ?name=xxx + * Get a single tag by uid or name. + */ + async getOne(req, res, next) { + try { + const { uid, name } = req.query; + const tag = await tagsService.getByQuery(req.currentUser.id, { + uid, + name, + }); + res.json(tag); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/tag + * Create a new tag. + */ + async create(req, res, next) { + try { + const { name } = req.body; + const tag = await tagsService.create(req.currentUser.id, name); + res.status(201).json(tag); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/tag/:identifier + * Update a tag's name. + */ + async update(req, res, next) { + try { + const { identifier } = req.params; + const { name } = req.body; + const tag = await tagsService.update( + req.currentUser.id, + identifier, + name + ); + res.json(tag); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/tag/:identifier + * Delete a tag. + */ + async delete(req, res, next) { + try { + const { identifier } = req.params; + const result = await tagsService.delete( + req.currentUser.id, + identifier + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = tagsController; diff --git a/backend/modules/tags/index.js b/backend/modules/tags/index.js new file mode 100644 index 0000000..000c2a5 --- /dev/null +++ b/backend/modules/tags/index.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Tags Module + * + * This module handles all tag-related functionality including: + * - CRUD operations for tags + * - Tag validation + * - Tag associations with tasks, notes, and projects + * + * Usage: + * const tagsModule = require('./modules/tags'); + * app.use('/api', tagsModule.routes); + */ + +const routes = require('./routes'); +const tagsService = require('./service'); +const tagsRepository = require('./repository'); +const { validateTagName } = require('./validation'); + +module.exports = { + routes, + tagsService, + tagsRepository, + validateTagName, +}; diff --git a/backend/modules/tags/repository.js b/backend/modules/tags/repository.js new file mode 100644 index 0000000..117337d --- /dev/null +++ b/backend/modules/tags/repository.js @@ -0,0 +1,113 @@ +'use strict'; + +const { Tag, sequelize } = require('../../models'); +const { Op } = require('sequelize'); +const BaseRepository = require('../../shared/database/BaseRepository'); + +class TagsRepository extends BaseRepository { + constructor() { + super(Tag); + } + + /** + * Find all tags for a user, ordered alphabetically (case-insensitive). + */ + async findAllByUser(userId) { + return this.model.findAll({ + where: { user_id: userId }, + attributes: ['id', 'uid', 'name'], + order: [[sequelize.fn('LOWER', sequelize.col('name')), 'ASC']], + }); + } + + /** + * Find a tag by uid or name for a specific user. + */ + async findByIdentifier(userId, identifier) { + return this.model.findOne({ + where: { + user_id: userId, + [Op.or]: [{ uid: identifier }, { name: identifier }], + }, + }); + } + + /** + * Find a tag by uid for a specific user. + */ + async findByUid(userId, uid) { + return this.model.findOne({ + where: { user_id: userId, uid }, + attributes: ['id', 'uid', 'name'], + }); + } + + /** + * Find a tag by name for a specific user. + */ + async findByName(userId, name) { + return this.model.findOne({ + where: { user_id: userId, name }, + }); + } + + /** + * Check if a tag name exists for a user (optionally excluding a specific tag). + */ + async nameExists(userId, name, excludeId = null) { + const where = { user_id: userId, name }; + if (excludeId) { + where.id = { [Op.ne]: excludeId }; + } + return this.exists(where); + } + + /** + * Create a new tag for a user. + */ + async createForUser(userId, name) { + return this.model.create({ + name, + user_id: userId, + }); + } + + /** + * Delete a tag and all its associations (tasks_tags, notes_tags, projects_tags). + */ + async deleteWithAssociations(tag) { + const transaction = await sequelize.transaction(); + + try { + // Remove associations from junction tables + await Promise.all([ + sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', { + replacements: [tag.id], + type: sequelize.QueryTypes.DELETE, + transaction, + }), + sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', { + replacements: [tag.id], + type: sequelize.QueryTypes.DELETE, + transaction, + }), + sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', { + replacements: [tag.id], + type: sequelize.QueryTypes.DELETE, + transaction, + }), + ]); + + await tag.destroy({ transaction }); + await transaction.commit(); + + return true; + } catch (error) { + await transaction.rollback(); + throw error; + } + } +} + +// Export singleton instance +module.exports = new TagsRepository(); diff --git a/backend/modules/tags/routes.js b/backend/modules/tags/routes.js new file mode 100644 index 0000000..b4c4d50 --- /dev/null +++ b/backend/modules/tags/routes.js @@ -0,0 +1,15 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const tagsController = require('./controller'); + +// All routes require authentication (handled by app.js middleware) + +router.get('/tags', tagsController.list); +router.get('/tag', tagsController.getOne); +router.post('/tag', tagsController.create); +router.patch('/tag/:identifier', tagsController.update); +router.delete('/tag/:identifier', tagsController.delete); + +module.exports = router; diff --git a/backend/modules/tags/service.js b/backend/modules/tags/service.js new file mode 100644 index 0000000..a02e2a8 --- /dev/null +++ b/backend/modules/tags/service.js @@ -0,0 +1,124 @@ +'use strict'; + +const tagsRepository = require('./repository'); +const { validateTagName } = require('./validation'); +const { NotFoundError, ConflictError } = require('../../shared/errors'); + +class TagsService { + /** + * Get all tags for a user. + */ + async getAllForUser(userId) { + const tags = await tagsRepository.findAllByUser(userId); + return tags.map((tag) => ({ + uid: tag.uid, + name: tag.name, + })); + } + + /** + * Get a single tag by uid or name. + */ + async getByQuery(userId, { uid, name }) { + let tag = null; + + if (uid) { + tag = await tagsRepository.findByUid(userId, uid); + } else if (name) { + tag = await tagsRepository.findByName( + userId, + decodeURIComponent(name) + ); + } + + if (!tag) { + throw new NotFoundError('Tag not found'); + } + + return { + uid: tag.uid, + name: tag.name, + }; + } + + /** + * Create a new tag. + */ + async create(userId, name) { + const validatedName = validateTagName(name); + + const exists = await tagsRepository.nameExists(userId, validatedName); + if (exists) { + throw new ConflictError( + `A tag with the name "${validatedName}" already exists.` + ); + } + + const tag = await tagsRepository.createForUser(userId, validatedName); + + return { + uid: tag.uid, + name: tag.name, + }; + } + + /** + * Update a tag's name. + */ + async update(userId, identifier, newName) { + const decodedIdentifier = decodeURIComponent(identifier); + const tag = await tagsRepository.findByIdentifier( + userId, + decodedIdentifier + ); + + if (!tag) { + throw new NotFoundError('Tag not found'); + } + + const validatedName = validateTagName(newName); + + // Check for name conflict if changing name + if (validatedName !== tag.name) { + const exists = await tagsRepository.nameExists( + userId, + validatedName, + tag.id + ); + if (exists) { + throw new ConflictError( + `A tag with the name "${validatedName}" already exists.` + ); + } + } + + await tagsRepository.update(tag, { name: validatedName }); + + return { + id: tag.id, + name: tag.name, + }; + } + + /** + * Delete a tag and all its associations. + */ + async delete(userId, identifier) { + const decodedIdentifier = decodeURIComponent(identifier); + const tag = await tagsRepository.findByIdentifier( + userId, + decodedIdentifier + ); + + if (!tag) { + throw new NotFoundError('Tag not found'); + } + + await tagsRepository.deleteWithAssociations(tag); + + return { message: 'Tag successfully deleted' }; + } +} + +// Export singleton instance +module.exports = new TagsService(); diff --git a/backend/services/tagsService.js b/backend/modules/tags/tagsService.js similarity index 100% rename from backend/services/tagsService.js rename to backend/modules/tags/tagsService.js diff --git a/backend/modules/tags/validation.js b/backend/modules/tags/validation.js new file mode 100644 index 0000000..9e76d89 --- /dev/null +++ b/backend/modules/tags/validation.js @@ -0,0 +1,42 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); + +const INVALID_CHARS = /[#%&{}\\<>*?/$!'"@+`|=]/; +const MAX_LENGTH = 50; + +/** + * Validates and sanitizes a tag name. + * @param {string} name - The tag name to validate + * @returns {string} - The sanitized tag name + * @throws {ValidationError} - If validation fails + */ +function validateTagName(name) { + if (!name || typeof name !== 'string') { + throw new ValidationError('Tag name is required'); + } + + const trimmed = name.trim(); + + if (trimmed.length === 0) { + throw new ValidationError('Tag name cannot be empty'); + } + + if (trimmed.length > MAX_LENGTH) { + throw new ValidationError( + `Tag name must be ${MAX_LENGTH} characters or less` + ); + } + + if (INVALID_CHARS.test(trimmed)) { + throw new ValidationError( + 'Tag name contains invalid characters. Please avoid: # % & { } \\ < > * ? / $ ! \' " @ + ` | =' + ); + } + + return trimmed; +} + +module.exports = { + validateTagName, +}; diff --git a/backend/routes/tasks/attachments.js b/backend/modules/tasks/attachments.js similarity index 100% rename from backend/routes/tasks/attachments.js rename to backend/modules/tasks/attachments.js diff --git a/backend/routes/tasks/core/builders.js b/backend/modules/tasks/core/builders.js similarity index 100% rename from backend/routes/tasks/core/builders.js rename to backend/modules/tasks/core/builders.js diff --git a/backend/routes/tasks/core/comparators.js b/backend/modules/tasks/core/comparators.js similarity index 100% rename from backend/routes/tasks/core/comparators.js rename to backend/modules/tasks/core/comparators.js diff --git a/backend/routes/tasks/core/parsers.js b/backend/modules/tasks/core/parsers.js similarity index 100% rename from backend/routes/tasks/core/parsers.js rename to backend/modules/tasks/core/parsers.js diff --git a/backend/routes/tasks/core/serializers.js b/backend/modules/tasks/core/serializers.js similarity index 97% rename from backend/routes/tasks/core/serializers.js rename to backend/modules/tasks/core/serializers.js index 4ac1122..e13d399 100644 --- a/backend/routes/tasks/core/serializers.js +++ b/backend/modules/tasks/core/serializers.js @@ -6,8 +6,8 @@ const { const { getTaskTodayMoveCount, getTaskTodayMoveCounts, -} = require('../../../services/taskEventService'); -const taskRepository = require('../../../repositories/TaskRepository'); +} = require('../taskEventService'); +const taskRepository = require('../repository'); // Sort tags alphabetically by name (case-insensitive) function sortTags(tags) { diff --git a/backend/services/deferredTaskService.js b/backend/modules/tasks/deferredTaskService.js similarity index 96% rename from backend/services/deferredTaskService.js rename to backend/modules/tasks/deferredTaskService.js index 0c17849..03d9231 100644 --- a/backend/services/deferredTaskService.js +++ b/backend/modules/tasks/deferredTaskService.js @@ -1,10 +1,10 @@ -const { Task, Notification, User } = require('../models'); +const { Task, Notification, User } = require('../../models'); const { Op } = require('sequelize'); -const { logError } = require('./logService'); +const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, -} = require('../utils/notificationPreferences'); +} = require('../../utils/notificationPreferences'); async function checkDeferredTasks() { try { diff --git a/backend/services/dueTaskService.js b/backend/modules/tasks/dueTaskService.js similarity index 97% rename from backend/services/dueTaskService.js rename to backend/modules/tasks/dueTaskService.js index a669ba9..04b5f62 100644 --- a/backend/services/dueTaskService.js +++ b/backend/modules/tasks/dueTaskService.js @@ -1,10 +1,10 @@ -const { Task, Notification, User } = require('../models'); +const { Task, Notification, User } = require('../../models'); const { Op } = require('sequelize'); -const { logError } = require('./logService'); +const { logError } = require('../../services/logService'); const { shouldSendInAppNotification, shouldSendTelegramNotification, -} = require('../utils/notificationPreferences'); +} = require('../../utils/notificationPreferences'); /** * Service to check for due and overdue tasks diff --git a/backend/routes/tasks/events.js b/backend/modules/tasks/events.js similarity index 99% rename from backend/routes/tasks/events.js rename to backend/modules/tasks/events.js index cf63ac2..d0d28b5 100644 --- a/backend/routes/tasks/events.js +++ b/backend/modules/tasks/events.js @@ -6,7 +6,7 @@ const { getTaskCompletionTime, getUserProductivityMetrics, getTaskActivitySummary, -} = require('../../services/taskEventService'); +} = require('./taskEventService'); const { logError } = require('../../services/logService'); const router = express.Router(); diff --git a/backend/modules/tasks/index.js b/backend/modules/tasks/index.js new file mode 100644 index 0000000..d893df1 --- /dev/null +++ b/backend/modules/tasks/index.js @@ -0,0 +1,5 @@ +'use strict'; + +const routes = require('./routes'); + +module.exports = { routes }; diff --git a/backend/routes/tasks/middleware/access.js b/backend/modules/tasks/middleware/access.js similarity index 100% rename from backend/routes/tasks/middleware/access.js rename to backend/modules/tasks/middleware/access.js diff --git a/backend/routes/tasks/operations/completion.js b/backend/modules/tasks/operations/completion.js similarity index 100% rename from backend/routes/tasks/operations/completion.js rename to backend/modules/tasks/operations/completion.js diff --git a/backend/routes/tasks/operations/grouping.js b/backend/modules/tasks/operations/grouping.js similarity index 100% rename from backend/routes/tasks/operations/grouping.js rename to backend/modules/tasks/operations/grouping.js diff --git a/backend/routes/tasks/operations/list.js b/backend/modules/tasks/operations/list.js similarity index 100% rename from backend/routes/tasks/operations/list.js rename to backend/modules/tasks/operations/list.js diff --git a/backend/routes/tasks/operations/parent-child.js b/backend/modules/tasks/operations/parent-child.js similarity index 98% rename from backend/routes/tasks/operations/parent-child.js rename to backend/modules/tasks/operations/parent-child.js index 3ec819d..28b9e1d 100644 --- a/backend/routes/tasks/operations/parent-child.js +++ b/backend/modules/tasks/operations/parent-child.js @@ -1,6 +1,6 @@ const { Task } = require('../../../models'); const { Op } = require('sequelize'); -const taskRepository = require('../../../repositories/TaskRepository'); +const taskRepository = require('../repository'); const { logError } = require('../../../services/logService'); async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) { diff --git a/backend/routes/tasks/operations/recurring.js b/backend/modules/tasks/operations/recurring.js similarity index 98% rename from backend/routes/tasks/operations/recurring.js rename to backend/modules/tasks/operations/recurring.js index c21085b..00a478e 100644 --- a/backend/routes/tasks/operations/recurring.js +++ b/backend/modules/tasks/operations/recurring.js @@ -1,8 +1,6 @@ const { Task } = require('../../../models'); -const taskRepository = require('../../../repositories/TaskRepository'); -const { - calculateNextDueDate, -} = require('../../../services/recurringTaskService'); +const taskRepository = require('../repository'); +const { calculateNextDueDate } = require('../recurringTaskService'); const { processDueDateForResponse, getSafeTimezone, diff --git a/backend/routes/tasks/operations/sorting.js b/backend/modules/tasks/operations/sorting.js similarity index 100% rename from backend/routes/tasks/operations/sorting.js rename to backend/modules/tasks/operations/sorting.js diff --git a/backend/routes/tasks/operations/subtasks.js b/backend/modules/tasks/operations/subtasks.js similarity index 98% rename from backend/routes/tasks/operations/subtasks.js rename to backend/modules/tasks/operations/subtasks.js index 76d6cde..b1c1bb8 100644 --- a/backend/routes/tasks/operations/subtasks.js +++ b/backend/modules/tasks/operations/subtasks.js @@ -1,5 +1,5 @@ const { Task, Tag, Project } = require('../../../models'); -const taskRepository = require('../../../repositories/TaskRepository'); +const taskRepository = require('../repository'); const permissionsService = require('../../../services/permissionsService'); const { logError } = require('../../../services/logService'); const { serializeTask } = require('../core/serializers'); diff --git a/backend/routes/tasks/operations/tags.js b/backend/modules/tasks/operations/tags.js similarity index 95% rename from backend/routes/tasks/operations/tags.js rename to backend/modules/tasks/operations/tags.js index 0e3fe2b..a3d1162 100644 --- a/backend/routes/tasks/operations/tags.js +++ b/backend/modules/tasks/operations/tags.js @@ -1,5 +1,5 @@ const { Tag } = require('../../../models'); -const { validateTagName } = require('../../../services/tagsService'); +const { validateTagName } = require('../../tags/tagsService'); async function updateTaskTags(task, tagsData, userId) { if (!tagsData) return; diff --git a/backend/routes/tasks/queries/metrics-computation.js b/backend/modules/tasks/queries/metrics-computation.js similarity index 100% rename from backend/routes/tasks/queries/metrics-computation.js rename to backend/modules/tasks/queries/metrics-computation.js diff --git a/backend/routes/tasks/queries/metrics-queries.js b/backend/modules/tasks/queries/metrics-queries.js similarity index 95% rename from backend/routes/tasks/queries/metrics-queries.js rename to backend/modules/tasks/queries/metrics-queries.js index 6f38be9..19b2d1d 100644 --- a/backend/routes/tasks/queries/metrics-queries.js +++ b/backend/modules/tasks/queries/metrics-queries.js @@ -5,7 +5,7 @@ const { getSafeTimezone, getTodayBoundsInUTC, } = require('../../../utils/timezone-utils'); -const { getTaskIncludeConfig } = require('./query-builders'); +const { getTaskIncludeConfigLight } = require('./query-builders'); // Statuses that indicate a task is in the "today plan" (actively being worked on) // Used to exclude from overdue/due-today sections to avoid duplicates @@ -31,6 +31,7 @@ async function countTotalOpenTasks(visibleTasksWhere) { parent_task_id: null, recurring_parent_id: null, }, + raw: true, }); } @@ -44,6 +45,7 @@ async function countTasksPendingOverMonth(visibleTasksWhere) { parent_task_id: null, recurring_parent_id: null, }, + raw: true, }); } @@ -55,7 +57,7 @@ async function fetchTasksInProgress(visibleTasksWhere) { parent_task_id: null, recurring_parent_id: null, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -117,7 +119,7 @@ async function fetchTodayPlanTasks(visibleTasksWhere) { }, ], }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -165,7 +167,7 @@ async function fetchTasksDueToday(visibleTasksWhere, userTimezone) { }, ], }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -206,7 +208,7 @@ async function fetchOverdueTasks(visibleTasksWhere, userTimezone) { }, ], }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -246,7 +248,7 @@ async function fetchNonProjectTasks( parent_task_id: null, recurring_parent_id: null, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -282,7 +284,7 @@ async function fetchProjectTasks( parent_task_id: null, recurring_parent_id: null, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -320,7 +322,7 @@ async function fetchSomedayFallbackTasks( parent_task_id: null, recurring_parent_id: null, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), order: [ ['priority', 'DESC'], ['due_date', 'ASC'], @@ -362,7 +364,7 @@ async function fetchTasksCompletedToday(userId, userTimezone) { [Op.lte]: todayBounds.end, }, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), }); // Fetch recurring tasks completed today via recurring_completions table @@ -383,7 +385,7 @@ async function fetchTasksCompletedToday(userId, userTimezone) { user_id: userId, parent_task_id: null, }, - include: getTaskIncludeConfig(), + include: getTaskIncludeConfigLight(), }, ], }); diff --git a/backend/routes/tasks/queries/query-builders.js b/backend/modules/tasks/queries/query-builders.js similarity index 96% rename from backend/routes/tasks/queries/query-builders.js rename to backend/modules/tasks/queries/query-builders.js index 439eb0c..059a985 100644 --- a/backend/routes/tasks/queries/query-builders.js +++ b/backend/modules/tasks/queries/query-builders.js @@ -415,7 +415,25 @@ function getTaskIncludeConfig() { ]; } +// Lightweight include config for dashboard lists (no subtasks needed) +function getTaskIncludeConfigLight() { + return [ + { + model: Tag, + attributes: ['id', 'name', 'uid'], + through: { attributes: [] }, + required: false, + }, + { + model: Project, + attributes: ['id', 'name', 'status', 'uid'], + required: false, + }, + ]; +} + module.exports = { filterTasksByParams, getTaskIncludeConfig, + getTaskIncludeConfigLight, }; diff --git a/backend/services/recurringTaskService.js b/backend/modules/tasks/recurringTaskService.js similarity index 100% rename from backend/services/recurringTaskService.js rename to backend/modules/tasks/recurringTaskService.js diff --git a/backend/repositories/TaskRepository.js b/backend/modules/tasks/repository.js similarity index 98% rename from backend/repositories/TaskRepository.js rename to backend/modules/tasks/repository.js index 9297640..74a9351 100644 --- a/backend/repositories/TaskRepository.js +++ b/backend/modules/tasks/repository.js @@ -1,4 +1,4 @@ -const { Task } = require('../models'); +const { Task } = require('../../models'); class TaskRepository { constructor() { diff --git a/backend/routes/tasks/index.js b/backend/modules/tasks/routes.js similarity index 99% rename from backend/routes/tasks/index.js rename to backend/modules/tasks/routes.js index 7f574db..30d636d 100644 --- a/backend/routes/tasks/index.js +++ b/backend/modules/tasks/routes.js @@ -11,7 +11,7 @@ const { RecurringCompletion, sequelize, } = require('../../models'); -const taskRepository = require('../../repositories/TaskRepository'); +const taskRepository = require('./repository'); const { resetQueryCounter, getQueryStats, @@ -22,9 +22,9 @@ const { calculateNextDueDate, calculateVirtualOccurrences, shouldGenerateNextTask, -} = require('../../services/recurringTaskService'); +} = require('./recurringTaskService'); const { logError } = require('../../services/logService'); -const { logEvent } = require('../../services/taskEventService'); +const { logEvent } = require('./taskEventService'); const { serializeTask, serializeTasks } = require('./core/serializers'); const { updateTaskTags } = require('./operations/tags'); diff --git a/backend/services/taskEventService.js b/backend/modules/tasks/taskEventService.js similarity index 99% rename from backend/services/taskEventService.js rename to backend/modules/tasks/taskEventService.js index 61e095b..8b962fa 100644 --- a/backend/services/taskEventService.js +++ b/backend/modules/tasks/taskEventService.js @@ -1,4 +1,4 @@ -const { TaskEvent, sequelize } = require('../models'); +const { TaskEvent, sequelize } = require('../../models'); // Helper function to create value object const createValueObject = (fieldName, value) => @@ -244,7 +244,7 @@ const getTaskTimeline = async (taskId) => { order: [['created_at', 'ASC']], include: [ { - model: require('../models').User, + model: require('../../models').User, as: 'User', attributes: ['id', 'name', 'email'], }, diff --git a/backend/services/taskScheduler.js b/backend/modules/tasks/taskScheduler.js similarity index 98% rename from backend/services/taskScheduler.js rename to backend/modules/tasks/taskScheduler.js index fd7a932..d0b735e 100644 --- a/backend/services/taskScheduler.js +++ b/backend/modules/tasks/taskScheduler.js @@ -1,7 +1,7 @@ const cron = require('node-cron'); -const { User } = require('../models'); +const { User } = require('../../models'); const TaskSummaryService = require('./taskSummaryService'); -const { setConfig, getConfig } = require('../config/config'); +const { setConfig, getConfig } = require('../../config/config'); const config = getConfig(); const createSchedulerState = () => ({ diff --git a/backend/services/taskSummaryService.js b/backend/modules/tasks/taskSummaryService.js similarity index 98% rename from backend/services/taskSummaryService.js rename to backend/modules/tasks/taskSummaryService.js index 2a80de7..e796453 100644 --- a/backend/services/taskSummaryService.js +++ b/backend/modules/tasks/taskSummaryService.js @@ -1,6 +1,6 @@ -const { User, Task, Project, Tag } = require('../models'); +const { User, Task, Project, Tag } = require('../../models'); const { Op } = require('sequelize'); -const TelegramPoller = require('./telegramPoller'); +const TelegramPoller = require('../telegram/telegramPoller'); // escape markdown special characters const escapeMarkdown = (text) => { diff --git a/backend/routes/tasks/utils/constants.js b/backend/modules/tasks/utils/constants.js similarity index 100% rename from backend/routes/tasks/utils/constants.js rename to backend/modules/tasks/utils/constants.js diff --git a/backend/routes/tasks/utils/logging.js b/backend/modules/tasks/utils/logging.js similarity index 97% rename from backend/routes/tasks/utils/logging.js rename to backend/modules/tasks/utils/logging.js index a9f3428..6407711 100644 --- a/backend/routes/tasks/utils/logging.js +++ b/backend/modules/tasks/utils/logging.js @@ -1,8 +1,5 @@ const { logError } = require('../../../services/logService'); -const { - logTaskUpdate, - logEvent, -} = require('../../../services/taskEventService'); +const { logTaskUpdate, logEvent } = require('../taskEventService'); function captureOldValues(task) { return { diff --git a/backend/routes/tasks/utils/validation.js b/backend/modules/tasks/utils/validation.js similarity index 100% rename from backend/routes/tasks/utils/validation.js rename to backend/modules/tasks/utils/validation.js diff --git a/backend/modules/telegram/controller.js b/backend/modules/telegram/controller.js new file mode 100644 index 0000000..4cd2c7d --- /dev/null +++ b/backend/modules/telegram/controller.js @@ -0,0 +1,99 @@ +'use strict'; + +const telegramService = require('./service'); +const { logError } = require('../../services/logService'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); + +const telegramController = { + async startPolling(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + const result = await telegramService.startPolling(userId); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + logError('Error starting Telegram polling:', error); + res.status(500).json({ + error: 'Failed to start Telegram polling.', + }); + } + }, + + async stopPolling(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + const result = await telegramService.stopPolling(userId); + res.json(result); + } catch (error) { + logError('Error stopping Telegram polling:', error); + res.status(500).json({ error: 'Failed to stop Telegram polling.' }); + } + }, + + async getPollingStatus(req, res, next) { + try { + const result = telegramService.getPollingStatus(); + res.json(result); + } catch (error) { + logError('Error getting Telegram polling status:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + + async setup(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + const { token } = req.body; + const result = await telegramService.setup(userId, token); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + if (error.statusCode === 404) { + return res.status(404).json({ error: error.message }); + } + logError('Error setting up Telegram:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + + async sendWelcome(req, res, next) { + try { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res + .status(401) + .json({ error: 'Authentication required' }); + } + const { chatId } = req.body; + const result = await telegramService.sendWelcome(userId, chatId); + res.json(result); + } catch (error) { + if (error.statusCode === 400) { + return res.status(400).json({ error: error.message }); + } + logError('Error sending welcome message:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +}; + +module.exports = telegramController; diff --git a/backend/modules/telegram/index.js b/backend/modules/telegram/index.js new file mode 100644 index 0000000..106b486 --- /dev/null +++ b/backend/modules/telegram/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const routes = require('./routes'); +const telegramService = require('./service'); + +module.exports = { routes, telegramService }; diff --git a/backend/modules/telegram/routes.js b/backend/modules/telegram/routes.js new file mode 100644 index 0000000..5ac7efa --- /dev/null +++ b/backend/modules/telegram/routes.js @@ -0,0 +1,13 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const telegramController = require('./controller'); + +router.post('/telegram/start-polling', telegramController.startPolling); +router.post('/telegram/stop-polling', telegramController.stopPolling); +router.get('/telegram/polling-status', telegramController.getPollingStatus); +router.post('/telegram/setup', telegramController.setup); +router.post('/telegram/send-welcome', telegramController.sendWelcome); + +module.exports = router; diff --git a/backend/routes/telegram.js b/backend/modules/telegram/service.js similarity index 53% rename from backend/routes/telegram.js rename to backend/modules/telegram/service.js index 74524f1..b4f5e6f 100644 --- a/backend/routes/telegram.js +++ b/backend/modules/telegram/service.js @@ -1,181 +1,15 @@ -const express = require('express'); -const { User } = require('../models'); -const { logError } = require('../services/logService'); -const telegramPoller = require('../services/telegramPoller'); -const { getBotInfo } = require('../services/telegramApi'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); +'use strict'; -const getUserIdOrUnauthorized = (req, res) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - res.status(401).json({ error: 'Authentication required' }); - return null; - } - return userId; -}; +const { User } = require('../../models'); +const { logError } = require('../../services/logService'); +const telegramPoller = require('./telegramPoller'); +const { getBotInfo } = require('./telegramApi'); +const { + NotFoundError, + ValidationError, + UnauthorizedError, +} = require('../../shared/errors'); -// POST /api/telegram/start-polling -router.post('/telegram/start-polling', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const user = await User.findByPk(userId); - if (!user || !user.telegram_bot_token) { - return res - .status(400) - .json({ error: 'Telegram bot token not set.' }); - } - - 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.', - }); - } - } catch (error) { - logError('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 { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const success = telegramPoller.removeUser(userId); - - res.json({ - success: true, - message: 'Telegram polling stopped', - status: telegramPoller.getStatus(), - }); - } catch (error) { - logError('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 { - res.json({ - success: true, - status: telegramPoller.getStatus(), - }); - } catch (error) { - logError('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 { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const { token } = req.body; - - if (!token) { - return res - .status(400) - .json({ error: 'Telegram bot token is required.' }); - } - - const user = await User.findByPk(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.' }); - } - - // Get bot info from Telegram API - // Skip actual API call in test environment - let botInfo; - if (process.env.NODE_ENV === 'test') { - // Mock response for tests - botInfo = { - id: 123456789, - is_bot: true, - first_name: 'Test Bot', - username: 'testbot', - }; - } else { - botInfo = await getBotInfo(token); - if (!botInfo) { - return res.status(400).json({ - error: 'Invalid bot token or bot not accessible.', - }); - } - } - - // Update user's telegram bot token - await user.update({ telegram_bot_token: token }); - - res.json({ - success: true, - message: 'Telegram bot token updated successfully', - bot: botInfo, - }); - } catch (error) { - logError('Error setting up Telegram:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// POST /api/telegram/send-welcome -router.post('/telegram/send-welcome', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const user = await User.findByPk(userId); - if (!user || !user.telegram_bot_token) { - return res - .status(400) - .json({ error: 'Telegram bot token not set.' }); - } - - const { chatId } = req.body; - if (!chatId) { - return res.status(400).json({ error: 'Chat ID is required.' }); - } - - // Send welcome message - const success = await sendWelcomeMessage( - user.telegram_bot_token, - chatId - ); - - if (success) { - res.json({ - success: true, - message: 'Welcome message sent successfully', - }); - } else { - res.status(500).json({ - error: 'Failed to send welcome message.', - }); - } - } catch (error) { - logError('Error sending welcome message:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Helper function to send welcome message async function sendWelcomeMessage(token, chatId) { return new Promise((resolve) => { const welcomeText = `🎉 Welcome to tududi!\n\nYour personal task management bot is now connected and ready to help!\n\n📝 Simply send me any message and I'll add it to your tududi inbox as an item.\n\n✨ Commands:\n• /help - Show help information\n• Just type any text - Add it as an inbox item\n\nLet's get organized! 🚀`; @@ -232,4 +66,113 @@ async function sendWelcomeMessage(token, chatId) { }); } -module.exports = router; +class TelegramService { + async startPolling(userId) { + const user = await User.findByPk(userId); + if (!user || !user.telegram_bot_token) { + throw new ValidationError('Telegram bot token not set.'); + } + + const success = await telegramPoller.addUser(user); + + if (success) { + return { + success: true, + message: 'Telegram polling started', + status: telegramPoller.getStatus(), + }; + } else { + throw new Error('Failed to start Telegram polling.'); + } + } + + async stopPolling(userId) { + const success = telegramPoller.removeUser(userId); + + return { + success: true, + message: 'Telegram polling stopped', + status: telegramPoller.getStatus(), + }; + } + + getPollingStatus() { + return { + success: true, + status: telegramPoller.getStatus(), + }; + } + + async setup(userId, token) { + if (!token) { + throw new ValidationError('Telegram bot token is required.'); + } + + const user = await User.findByPk(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + // Basic token validation - check if it looks like a Telegram bot token + if (!/^\d+:[A-Za-z0-9_-]{35}$/.test(token)) { + throw new ValidationError('Invalid Telegram bot token format.'); + } + + // Get bot info from Telegram API + // Skip actual API call in test environment + let botInfo; + if (process.env.NODE_ENV === 'test') { + // Mock response for tests + botInfo = { + id: 123456789, + is_bot: true, + first_name: 'Test Bot', + username: 'testbot', + }; + } else { + botInfo = await getBotInfo(token); + if (!botInfo) { + throw new ValidationError( + 'Invalid bot token or bot not accessible.' + ); + } + } + + // Update user's telegram bot token + await user.update({ telegram_bot_token: token }); + + return { + success: true, + message: 'Telegram bot token updated successfully', + bot: botInfo, + }; + } + + async sendWelcome(userId, chatId) { + const user = await User.findByPk(userId); + if (!user || !user.telegram_bot_token) { + throw new ValidationError('Telegram bot token not set.'); + } + + if (!chatId) { + throw new ValidationError('Chat ID is required.'); + } + + // Send welcome message + const success = await sendWelcomeMessage( + user.telegram_bot_token, + chatId + ); + + if (success) { + return { + success: true, + message: 'Welcome message sent successfully', + }; + } else { + throw new Error('Failed to send welcome message.'); + } + } +} + +module.exports = new TelegramService(); diff --git a/backend/services/telegramApi.js b/backend/modules/telegram/telegramApi.js similarity index 100% rename from backend/services/telegramApi.js rename to backend/modules/telegram/telegramApi.js diff --git a/backend/services/telegramInitializer.js b/backend/modules/telegram/telegramInitializer.js similarity index 89% rename from backend/services/telegramInitializer.js rename to backend/modules/telegram/telegramInitializer.js index 1c15522..0be0851 100644 --- a/backend/services/telegramInitializer.js +++ b/backend/modules/telegram/telegramInitializer.js @@ -1,6 +1,6 @@ const telegramPoller = require('./telegramPoller'); -const { User } = require('../models'); -const { setConfig, getConfig } = require('../config/config'); +const { User } = require('../../models'); +const { setConfig, getConfig } = require('../../config/config'); const config = getConfig(); async function initializeTelegramPolling() { diff --git a/backend/services/telegramNotificationService.js b/backend/modules/telegram/telegramNotificationService.js similarity index 100% rename from backend/services/telegramNotificationService.js rename to backend/modules/telegram/telegramNotificationService.js diff --git a/backend/services/telegramPoller.js b/backend/modules/telegram/telegramPoller.js similarity index 99% rename from backend/services/telegramPoller.js rename to backend/modules/telegram/telegramPoller.js index eabb7d4..f4d558e 100644 --- a/backend/services/telegramPoller.js +++ b/backend/modules/telegram/telegramPoller.js @@ -1,5 +1,5 @@ const https = require('https'); -const { User, InboxItem } = require('../models'); +const { User, InboxItem } = require('../../models'); // Create poller state const createPollerState = () => ({ diff --git a/backend/modules/url/controller.js b/backend/modules/url/controller.js new file mode 100644 index 0000000..c5dd658 --- /dev/null +++ b/backend/modules/url/controller.js @@ -0,0 +1,44 @@ +'use strict'; + +const urlService = require('./service'); +const { logError } = require('../../services/logService'); + +const urlController = { + async getTitle(req, res, next) { + try { + const { url } = req.query; + + if (!url) { + return res + .status(400) + .json({ error: 'URL parameter is required' }); + } + + const result = await urlService.getTitle(url); + res.json(result); + } catch (error) { + logError('Error extracting URL title:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, + + async extractFromText(req, res, next) { + try { + const { text } = req.body; + + if (!text) { + return res + .status(400) + .json({ error: 'Text parameter is required' }); + } + + const result = await urlService.extractFromText(text); + res.json(result); + } catch (error) { + logError('Error extracting URL from text:', error); + res.status(500).json({ error: 'Internal server error' }); + } + }, +}; + +module.exports = urlController; diff --git a/backend/modules/url/index.js b/backend/modules/url/index.js new file mode 100644 index 0000000..75b8b68 --- /dev/null +++ b/backend/modules/url/index.js @@ -0,0 +1,6 @@ +'use strict'; + +const routes = require('./routes'); +const urlService = require('./service'); + +module.exports = { routes, urlService }; diff --git a/backend/modules/url/routes.js b/backend/modules/url/routes.js new file mode 100644 index 0000000..ca1e885 --- /dev/null +++ b/backend/modules/url/routes.js @@ -0,0 +1,10 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const urlController = require('./controller'); + +router.get('/url/title', urlController.getTitle); +router.post('/url/extract-from-text', urlController.extractFromText); + +module.exports = router; diff --git a/backend/routes/url.js b/backend/modules/url/service.js similarity index 91% rename from backend/routes/url.js rename to backend/modules/url/service.js index 75abdc9..aa675c1 100644 --- a/backend/routes/url.js +++ b/backend/modules/url/service.js @@ -1,9 +1,9 @@ -const express = require('express'); +'use strict'; + const https = require('https'); const http = require('http'); const { URL } = require('url'); -const { logError } = require('../services/logService'); -const router = express.Router(); +const { logError } = require('../../services/logService'); let nodeFetchInstance = null; try { @@ -141,13 +141,6 @@ function extractMetadataFromHtml(html) { } } -// 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()); -} - // Helper function to resolve relative URLs to absolute URLs function resolveUrl(baseUrl, relativeUrl) { try { @@ -203,59 +196,6 @@ const finalizeMetadata = (metadata, sourceUrl) => { return enriched; }; -// Helper function to fetch URL metadata with proper redirect/timeout handling -async function fetchUrlMetadata(url) { - if (!url) { - return null; - } - - let normalizedUrl = url.trim(); - if ( - !normalizedUrl.startsWith('http://') && - !normalizedUrl.startsWith('https://') - ) { - normalizedUrl = `https://${normalizedUrl}`; - } - - // Handle YouTube URLs specially to avoid anti-bot issues - if ( - normalizedUrl.includes('youtube.com') || - normalizedUrl.includes('youtu.be') - ) { - const youtubeMetadata = handleYouTubeUrl(normalizedUrl); - if (youtubeMetadata) { - return youtubeMetadata; - } - } - - try { - if (getFetchImplementation()) { - const metadata = await fetchMetadataViaFetch(normalizedUrl); - if (metadata) { - return metadata; - } - } - } catch (error) { - logError('Error fetching URL metadata via fetch:', error); - } - - const httpMetadata = await fetchMetadataViaHttp(normalizedUrl); - if (httpMetadata) { - return httpMetadata; - } - - try { - const proxyMetadata = await fetchMetadataViaProxy(normalizedUrl); - if (proxyMetadata) { - return proxyMetadata; - } - } catch (error) { - logError('Error fetching URL metadata via proxy:', error); - } - - return null; -} - async function fetchMetadataViaFetch(normalizedUrl) { const response = await fetchWithTimeout( normalizedUrl, @@ -446,48 +386,88 @@ async function fetchMetadataViaProxy(normalizedUrl) { return finalizeMetadata(extractMetadataFromHtml(html), normalizedUrl); } -// GET /api/url/title -router.get('/url/title', async (req, res) => { - try { - const { url } = req.query; +// Helper function to fetch URL metadata with proper redirect/timeout handling +async function fetchUrlMetadata(url) { + if (!url) { + return null; + } + let normalizedUrl = url.trim(); + if ( + !normalizedUrl.startsWith('http://') && + !normalizedUrl.startsWith('https://') + ) { + normalizedUrl = `https://${normalizedUrl}`; + } + + // Handle YouTube URLs specially to avoid anti-bot issues + if ( + normalizedUrl.includes('youtube.com') || + normalizedUrl.includes('youtu.be') + ) { + const youtubeMetadata = handleYouTubeUrl(normalizedUrl); + if (youtubeMetadata) { + return youtubeMetadata; + } + } + + try { + if (getFetchImplementation()) { + const metadata = await fetchMetadataViaFetch(normalizedUrl); + if (metadata) { + return metadata; + } + } + } catch (error) { + logError('Error fetching URL metadata via fetch:', error); + } + + const httpMetadata = await fetchMetadataViaHttp(normalizedUrl); + if (httpMetadata) { + return httpMetadata; + } + + try { + const proxyMetadata = await fetchMetadataViaProxy(normalizedUrl); + if (proxyMetadata) { + return proxyMetadata; + } + } catch (error) { + logError('Error fetching URL metadata via proxy:', error); + } + + return null; +} + +class UrlService { + async getTitle(url) { if (!url) { - return res.status(400).json({ error: 'URL parameter is required' }); + return { error: 'URL parameter is required' }; } const metadata = await fetchUrlMetadata(url); if (metadata && metadata.title) { - res.json({ + return { url, title: metadata.title, image: metadata.image, description: metadata.description, - }); + }; } else { - res.json({ + return { url, title: null, image: null, description: null, error: 'Could not extract metadata', - }); + }; } - } catch (error) { - logError('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 { - const { text } = req.body; + async extractFromText(text) { if (!text) { - return res - .status(400) - .json({ error: 'Text parameter is required' }); + return { error: 'Text parameter is required' }; } // Enhanced URL extraction - look for URLs with or without protocol @@ -511,31 +491,28 @@ router.post('/url/extract-from-text', async (req, res) => { const metadata = await fetchUrlMetadata(firstUrl); if (metadata && metadata.title) { - res.json({ + return { found: true, url: firstUrl, title: metadata.title, image: metadata.image, description: metadata.description, originalText: text, - }); + }; } else { - res.json({ + return { found: true, url: firstUrl, title: null, image: null, description: null, originalText: text, - }); + }; } } else { - res.json({ found: false }); + return { found: false }; } - } catch (error) { - logError('Error extracting URL from text:', error); - res.status(500).json({ error: 'Internal server error' }); } -}); +} -module.exports = router; +module.exports = new UrlService(); diff --git a/backend/services/apiTokenService.js b/backend/modules/users/apiTokenService.js similarity index 98% rename from backend/services/apiTokenService.js rename to backend/modules/users/apiTokenService.js index daca73d..3940977 100644 --- a/backend/services/apiTokenService.js +++ b/backend/modules/users/apiTokenService.js @@ -1,6 +1,6 @@ const crypto = require('crypto'); const bcrypt = require('bcrypt'); -const { ApiToken } = require('../models'); +const { ApiToken } = require('../../models'); const TOKEN_PREFIX_LENGTH = 12; diff --git a/backend/modules/users/controller.js b/backend/modules/users/controller.js new file mode 100644 index 0000000..112485e --- /dev/null +++ b/backend/modules/users/controller.js @@ -0,0 +1,291 @@ +'use strict'; + +const usersService = require('./service'); +const { UnauthorizedError } = require('../../shared/errors'); +const { getAuthenticatedUserId } = require('../../utils/request-utils'); +const { logError } = require('../../services/logService'); +const fs = require('fs').promises; + +/** + * Get authenticated user ID or throw UnauthorizedError. + */ +function requireUserId(req) { + const userId = getAuthenticatedUserId(req); + if (!userId) { + throw new UnauthorizedError('Authentication required'); + } + return userId; +} + +/** + * Users controller - handles HTTP requests/responses. + */ +const usersController = { + /** + * GET /api/users + * List all users. + */ + async list(req, res, next) { + try { + const users = await usersService.listUsers(); + res.json(users); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/profile + * Get current user profile. + */ + async getProfile(req, res, next) { + try { + const userId = requireUserId(req); + const profile = await usersService.getProfile(userId); + res.json(profile); + } catch (error) { + next(error); + } + }, + + /** + * PATCH /api/profile + * Update current user profile. + */ + async updateProfile(req, res, next) { + try { + const userId = requireUserId(req); + const profile = await usersService.updateProfile(userId, req.body); + res.json(profile); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/avatar + * Upload avatar. + */ + async uploadAvatar(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.uploadAvatar(userId, req.file); + res.json(result); + } catch (error) { + if (req.file) { + await fs.unlink(req.file.path).catch(() => {}); + } + next(error); + } + }, + + /** + * DELETE /api/profile/avatar + * Delete avatar. + */ + async deleteAvatar(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.deleteAvatar(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/change-password + * Change password. + */ + async changePassword(req, res, next) { + try { + const userId = requireUserId(req); + const { currentPassword, newPassword } = req.body; + const result = await usersService.changePassword( + userId, + currentPassword, + newPassword + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/profile/api-keys + * List API keys. + */ + async listApiKeys(req, res, next) { + try { + const userId = requireUserId(req); + const keys = await usersService.listApiKeys(userId); + res.json(keys); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/api-keys + * Create API key. + */ + async createApiKey(req, res, next) { + try { + const userId = requireUserId(req); + const { name, expires_at } = req.body || {}; + const result = await usersService.createApiKey( + userId, + name, + expires_at + ); + res.status(201).json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/api-keys/:id/revoke + * Revoke API key. + */ + async revokeApiKey(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.revokeApiKey( + userId, + req.params.id + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * DELETE /api/profile/api-keys/:id + * Delete API key. + */ + async deleteApiKey(req, res, next) { + try { + const userId = requireUserId(req); + await usersService.deleteApiKey(userId, req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/task-summary/toggle + * Toggle task summary. + */ + async toggleTaskSummary(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.toggleTaskSummary(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/task-summary/frequency + * Update task summary frequency. + */ + async updateTaskSummaryFrequency(req, res, next) { + try { + const userId = requireUserId(req); + const { frequency } = req.body; + const result = await usersService.updateTaskSummaryFrequency( + userId, + frequency + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * POST /api/profile/task-summary/send-now + * Send task summary now. + */ + async sendTaskSummaryNow(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.sendTaskSummaryNow(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * GET /api/profile/task-summary/status + * Get task summary status. + */ + async getTaskSummaryStatus(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.getTaskSummaryStatus(userId); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/profile/today-settings + * Update today settings. + */ + async updateTodaySettings(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.updateTodaySettings( + userId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/profile/sidebar-settings + * Update sidebar settings. + */ + async updateSidebarSettings(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.updateSidebarSettings( + userId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, + + /** + * PUT /api/profile/ui-settings + * Update UI settings. + */ + async updateUiSettings(req, res, next) { + try { + const userId = requireUserId(req); + const result = await usersService.updateUiSettings( + userId, + req.body + ); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = usersController; diff --git a/backend/modules/users/index.js b/backend/modules/users/index.js new file mode 100644 index 0000000..58ea1d9 --- /dev/null +++ b/backend/modules/users/index.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Users Module + * + * This module handles all user-related functionality including: + * - User listing + * - Profile management (get, update) + * - Avatar upload/delete + * - Password change + * - API key management + * - Task summary settings + * - Today/Sidebar/UI settings + * + * Usage: + * const usersModule = require('./modules/users'); + * app.use('/api', usersModule.routes); + */ + +const routes = require('./routes'); +const usersService = require('./service'); +const usersRepository = require('./repository'); +const { + VALID_FREQUENCIES, + validateFirstDayOfWeek, + validatePassword, + validateFrequency, + validateApiKeyId, + validateApiKeyName, + validateExpiresAt, + validateSidebarSettings, +} = require('./validation'); + +module.exports = { + routes, + usersService, + usersRepository, + VALID_FREQUENCIES, + validateFirstDayOfWeek, + validatePassword, + validateFrequency, + validateApiKeyId, + validateApiKeyName, + validateExpiresAt, + validateSidebarSettings, +}; diff --git a/backend/modules/users/repository.js b/backend/modules/users/repository.js new file mode 100644 index 0000000..b72d225 --- /dev/null +++ b/backend/modules/users/repository.js @@ -0,0 +1,117 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { User, Role, ApiToken } = require('../../models'); + +const PROFILE_ATTRIBUTES = [ + 'uid', + 'email', + 'name', + 'surname', + 'appearance', + 'language', + 'timezone', + 'first_day_of_week', + 'avatar_image', + 'telegram_bot_token', + 'telegram_chat_id', + 'telegram_allowed_users', + 'task_summary_enabled', + 'task_summary_frequency', + 'task_intelligence_enabled', + 'auto_suggest_next_actions_enabled', + 'pomodoro_enabled', + 'today_settings', + 'sidebar_settings', + 'productivity_assistant_enabled', + 'next_task_suggestion_enabled', + 'notification_preferences', + 'keyboard_shortcuts', +]; + +const PROFILE_UPDATE_ATTRIBUTES = [ + 'uid', + 'email', + 'name', + 'surname', + 'appearance', + 'language', + 'timezone', + 'avatar_image', + 'telegram_bot_token', + 'telegram_chat_id', + 'telegram_allowed_users', + 'task_intelligence_enabled', + 'task_summary_enabled', + 'task_summary_frequency', + 'auto_suggest_next_actions_enabled', + 'productivity_assistant_enabled', + 'next_task_suggestion_enabled', + 'pomodoro_enabled', + 'notification_preferences', + 'keyboard_shortcuts', +]; + +class UsersRepository extends BaseRepository { + constructor() { + super(User); + } + + /** + * Find all users with basic attributes. + */ + async findAllBasic() { + return this.model.findAll({ + attributes: ['id', 'email', 'name', 'surname'], + order: [['email', 'ASC']], + }); + } + + /** + * Find all roles. + */ + async findAllRoles() { + return Role.findAll({ + attributes: ['user_id', 'is_admin'], + }); + } + + /** + * Find user profile by ID. + */ + async findProfileById(userId) { + return this.model.findByPk(userId, { + attributes: PROFILE_ATTRIBUTES, + }); + } + + /** + * Find user with password digest. + */ + async findByIdWithPassword(userId) { + return this.model.findByPk(userId); + } + + /** + * Find updated user profile. + */ + async findUpdatedProfile(userId) { + return this.model.findByPk(userId, { + attributes: PROFILE_UPDATE_ATTRIBUTES, + }); + } + + /** + * Find all API tokens for a user. + */ + async findApiTokens(userId) { + return ApiToken.findAll({ + where: { user_id: userId }, + order: [['created_at', 'DESC']], + }); + } +} + +module.exports = new UsersRepository(); +module.exports.PROFILE_ATTRIBUTES = PROFILE_ATTRIBUTES; +module.exports.PROFILE_UPDATE_ATTRIBUTES = PROFILE_UPDATE_ATTRIBUTES; diff --git a/backend/modules/users/routes.js b/backend/modules/users/routes.js new file mode 100644 index 0000000..37d971b --- /dev/null +++ b/backend/modules/users/routes.js @@ -0,0 +1,114 @@ +'use strict'; + +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs').promises; +const router = express.Router(); +const usersController = require('./controller'); +const { apiKeyManagementLimiter } = require('../../middleware/rateLimiter'); + +// Configure multer for avatar uploads +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../uploads/avatars'); + try { + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + } catch (error) { + cb(error, uploadDir); + } + }, + filename: (req, file, cb) => { + const userId = req.currentUser?.id || req.session?.userId; + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, `avatar-${userId}-${uniqueSuffix}${ext}`); + }, +}); + +const fileFilter = (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 (JPEG, PNG, GIF, WebP) are allowed!')); + } +}; + +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024, // 5MB limit + }, + fileFilter: fileFilter, +}); + +// All routes require authentication (handled by app.js middleware) + +// Users list +router.get('/users', usersController.list); + +// Profile routes +router.get('/profile', usersController.getProfile); +router.patch('/profile', usersController.updateProfile); + +// Avatar routes +router.post( + '/profile/avatar', + upload.single('avatar'), + usersController.uploadAvatar +); +router.delete('/profile/avatar', usersController.deleteAvatar); + +// Password change +router.post('/profile/change-password', usersController.changePassword); + +// API keys (with rate limiting) +router.get( + '/profile/api-keys', + apiKeyManagementLimiter, + usersController.listApiKeys +); +router.post( + '/profile/api-keys', + apiKeyManagementLimiter, + usersController.createApiKey +); +router.post( + '/profile/api-keys/:id/revoke', + apiKeyManagementLimiter, + usersController.revokeApiKey +); +router.delete( + '/profile/api-keys/:id', + apiKeyManagementLimiter, + usersController.deleteApiKey +); + +// Task summary routes +router.post('/profile/task-summary/toggle', usersController.toggleTaskSummary); +router.post( + '/profile/task-summary/frequency', + usersController.updateTaskSummaryFrequency +); +router.post( + '/profile/task-summary/send-now', + usersController.sendTaskSummaryNow +); +router.get( + '/profile/task-summary/status', + usersController.getTaskSummaryStatus +); + +// Settings routes +router.put('/profile/today-settings', usersController.updateTodaySettings); +router.put('/profile/sidebar-settings', usersController.updateSidebarSettings); +router.put('/profile/ui-settings', usersController.updateUiSettings); + +module.exports = router; diff --git a/backend/modules/users/service.js b/backend/modules/users/service.js new file mode 100644 index 0000000..003ff06 --- /dev/null +++ b/backend/modules/users/service.js @@ -0,0 +1,527 @@ +'use strict'; + +const usersRepository = require('./repository'); +const { + validateFirstDayOfWeek, + validatePassword, + validateFrequency, + validateApiKeyId, + validateApiKeyName, + validateExpiresAt, + validateSidebarSettings, +} = require('./validation'); +const { NotFoundError, ValidationError } = require('../../shared/errors'); +const { User } = require('../../models'); +const { + createApiToken, + revokeApiToken, + deleteApiToken, + serializeApiToken, +} = require('./apiTokenService'); +const taskSummaryService = require('../tasks/taskSummaryService'); +const { logError } = require('../../services/logService'); +const fs = require('fs').promises; +const path = require('path'); + +class UsersService { + /** + * List all users with roles. + */ + async listUsers() { + const users = await usersRepository.findAllBasic(); + const roles = await usersRepository.findAllRoles(); + const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin])); + + return users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + surname: u.surname, + role: userIdToRole.get(u.id) ? 'admin' : 'user', + })); + } + + /** + * Get user profile. + */ + async getProfile(userId) { + const user = await usersRepository.findProfileById(userId); + if (!user) { + throw new NotFoundError('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) { + logError('Error parsing today_settings:', error); + user.today_settings = null; + } + } + if (user.ui_settings && typeof user.ui_settings === 'string') { + try { + user.ui_settings = JSON.parse(user.ui_settings); + } catch (error) { + logError('Error parsing ui_settings:', error); + user.ui_settings = null; + } + } + + return user; + } + + /** + * Update user profile. + */ + async updateProfile(userId, data) { + const user = await usersRepository.findByIdWithPassword(userId); + if (!user) { + throw new NotFoundError('Profile not found.'); + } + + const { + name, + surname, + appearance, + language, + timezone, + first_day_of_week, + avatar_image, + telegram_bot_token, + telegram_allowed_users, + task_intelligence_enabled, + task_summary_enabled, + task_summary_frequency, + auto_suggest_next_actions_enabled, + productivity_assistant_enabled, + next_task_suggestion_enabled, + pomodoro_enabled, + ui_settings, + notification_preferences, + keyboard_shortcuts, + currentPassword, + newPassword, + } = data; + + const allowedUpdates = {}; + if (name !== undefined) allowedUpdates.name = name; + if (surname !== undefined) allowedUpdates.surname = surname; + if (appearance !== undefined) allowedUpdates.appearance = appearance; + if (language !== undefined) allowedUpdates.language = language; + if (timezone !== undefined) allowedUpdates.timezone = timezone; + if (first_day_of_week !== undefined) { + validateFirstDayOfWeek(first_day_of_week); + allowedUpdates.first_day_of_week = first_day_of_week; + } + if (avatar_image !== undefined) + allowedUpdates.avatar_image = avatar_image; + if (telegram_bot_token !== undefined) + allowedUpdates.telegram_bot_token = telegram_bot_token; + if (telegram_allowed_users !== undefined) + allowedUpdates.telegram_allowed_users = telegram_allowed_users; + 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 (productivity_assistant_enabled !== undefined) + allowedUpdates.productivity_assistant_enabled = + productivity_assistant_enabled; + if (next_task_suggestion_enabled !== undefined) + allowedUpdates.next_task_suggestion_enabled = + next_task_suggestion_enabled; + if (pomodoro_enabled !== undefined) + allowedUpdates.pomodoro_enabled = pomodoro_enabled; + if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings; + if (notification_preferences !== undefined) + allowedUpdates.notification_preferences = notification_preferences; + if (keyboard_shortcuts !== undefined) + allowedUpdates.keyboard_shortcuts = keyboard_shortcuts; + + // Handle password change if provided + if (currentPassword && newPassword) { + validatePassword(newPassword, 'newPassword'); + + const isValidPassword = await User.checkPassword( + currentPassword, + user.password_digest + ); + if (!isValidPassword) { + throw new ValidationError( + 'Current password is incorrect', + 'currentPassword' + ); + } + + const hashedNewPassword = await User.hashPassword(newPassword); + allowedUpdates.password_digest = hashedNewPassword; + } + + await usersRepository.update(user, allowedUpdates); + return usersRepository.findUpdatedProfile(user.id); + } + + /** + * Upload avatar. + */ + async uploadAvatar(userId, file) { + if (!file) { + throw new ValidationError('No file uploaded'); + } + + const user = await usersRepository.findById(userId); + if (!user) { + await fs.unlink(file.path).catch(() => {}); + throw new NotFoundError('User not found'); + } + + // Delete old avatar file if it exists + if (user.avatar_image) { + const oldAvatarPath = path.join( + __dirname, + '../../uploads/avatars', + path.basename(user.avatar_image) + ); + await fs.unlink(oldAvatarPath).catch(() => {}); + } + + const avatarUrl = `/uploads/avatars/${path.basename(file.path)}`; + await usersRepository.update(user, { avatar_image: avatarUrl }); + + return { + success: true, + avatar_image: avatarUrl, + message: 'Avatar uploaded successfully', + }; + } + + /** + * Delete avatar. + */ + async deleteAvatar(userId) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found'); + } + + if (user.avatar_image) { + const avatarPath = path.join( + __dirname, + '../../uploads/avatars', + path.basename(user.avatar_image) + ); + await fs.unlink(avatarPath).catch(() => {}); + } + + await usersRepository.update(user, { avatar_image: null }); + + return { success: true, message: 'Avatar removed successfully' }; + } + + /** + * Change password. + */ + async changePassword(userId, currentPassword, newPassword) { + if (!currentPassword || !newPassword) { + throw new ValidationError( + 'Current password and new password are required' + ); + } + + validatePassword(newPassword, 'newPassword'); + + const user = await usersRepository.findByIdWithPassword(userId); + if (!user) { + throw new NotFoundError('User not found'); + } + + const isValidPassword = await User.checkPassword( + currentPassword, + user.password_digest + ); + if (!isValidPassword) { + throw new ValidationError( + 'Current password is incorrect', + 'currentPassword' + ); + } + + const hashedNewPassword = await User.hashPassword(newPassword); + await usersRepository.update(user, { + password_digest: hashedNewPassword, + }); + + return { message: 'Password changed successfully' }; + } + + /** + * List API keys. + */ + async listApiKeys(userId) { + const tokens = await usersRepository.findApiTokens(userId); + return tokens.map(serializeApiToken); + } + + /** + * Create API key. + */ + async createApiKey(userId, name, expires_at) { + const validatedName = validateApiKeyName(name); + const expiresAtDate = validateExpiresAt(expires_at); + + const { rawToken, tokenRecord } = await createApiToken({ + userId, + name: validatedName, + expiresAt: expiresAtDate, + }); + + return { + token: rawToken, + apiKey: serializeApiToken(tokenRecord), + }; + } + + /** + * Revoke API key. + */ + async revokeApiKey(userId, keyId) { + const tokenId = validateApiKeyId(keyId); + const token = await revokeApiToken(tokenId, userId); + if (!token) { + throw new NotFoundError('API key not found.'); + } + return serializeApiToken(token); + } + + /** + * Delete API key. + */ + async deleteApiKey(userId, keyId) { + const tokenId = validateApiKeyId(keyId); + const deleted = await deleteApiToken(tokenId, userId); + if (!deleted) { + throw new NotFoundError('API key not found.'); + } + return null; + } + + /** + * Toggle task summary. + */ + async toggleTaskSummary(userId) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + const enabled = !user.task_summary_enabled; + await usersRepository.update(user, { task_summary_enabled: enabled }); + + return { + success: true, + enabled, + message: enabled + ? 'Task summary notifications have been enabled.' + : 'Task summary notifications have been disabled.', + }; + } + + /** + * Update task summary frequency. + */ + async updateTaskSummaryFrequency(userId, frequency) { + const validatedFrequency = validateFrequency(frequency); + + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + await usersRepository.update(user, { + task_summary_frequency: validatedFrequency, + }); + + return { + success: true, + frequency: validatedFrequency, + message: `Task summary frequency has been set to ${validatedFrequency}.`, + }; + } + + /** + * Send task summary now. + */ + async sendTaskSummaryNow(userId) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + if (!user.telegram_bot_token || !user.telegram_chat_id) { + throw new ValidationError( + 'Telegram bot is not properly configured.' + ); + } + + const success = await taskSummaryService.sendSummaryToUser(user.id); + + if (!success) { + throw new ValidationError('Failed to send message to Telegram.'); + } + + return { + success: true, + message: 'Task summary was sent to your Telegram.', + }; + } + + /** + * Get task summary status. + */ + async getTaskSummaryStatus(userId) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + return { + 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, + }; + } + + /** + * Update today settings. + */ + async updateTodaySettings(userId, data) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + const { + showMetrics, + projectShowMetrics, + showProductivity, + showNextTaskSuggestion, + showSuggestions, + showDueToday, + showCompleted, + showDailyQuote, + } = data; + + const todaySettings = { + projectShowMetrics: + projectShowMetrics !== undefined + ? projectShowMetrics + : (user.today_settings?.projectShowMetrics ?? true), + showMetrics: + showMetrics !== undefined + ? showMetrics + : user.today_settings?.showMetrics || false, + showProductivity: + showProductivity !== undefined + ? showProductivity + : user.today_settings?.showProductivity || false, + showNextTaskSuggestion: + showNextTaskSuggestion !== undefined + ? showNextTaskSuggestion + : user.today_settings?.showNextTaskSuggestion || false, + showSuggestions: + showSuggestions !== undefined + ? showSuggestions + : user.today_settings?.showSuggestions || false, + showDueToday: + showDueToday !== undefined + ? showDueToday + : user.today_settings?.showDueToday || true, + showCompleted: + showCompleted !== undefined + ? showCompleted + : user.today_settings?.showCompleted || true, + showProgressBar: true, + showDailyQuote: + showDailyQuote !== undefined + ? showDailyQuote + : user.today_settings?.showDailyQuote || true, + }; + + const profileUpdates = { today_settings: todaySettings }; + if (showProductivity !== undefined) { + profileUpdates.productivity_assistant_enabled = showProductivity; + } + if (showNextTaskSuggestion !== undefined) { + profileUpdates.next_task_suggestion_enabled = + showNextTaskSuggestion; + } + + await usersRepository.update(user, profileUpdates); + + return { success: true, today_settings: todaySettings }; + } + + /** + * Update sidebar settings. + */ + async updateSidebarSettings(userId, data) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + const { pinnedViewsOrder } = validateSidebarSettings(data); + const sidebarSettings = { pinnedViewsOrder }; + + await usersRepository.update(user, { + sidebar_settings: sidebarSettings, + }); + + return { success: true, sidebar_settings: sidebarSettings }; + } + + /** + * Update UI settings. + */ + async updateUiSettings(userId, data) { + const user = await usersRepository.findById(userId); + if (!user) { + throw new NotFoundError('User not found.'); + } + + const { project } = data; + + const currentSettings = + user.ui_settings && typeof user.ui_settings === 'object' + ? user.ui_settings + : { project: { details: {} } }; + + const newSettings = { + ...currentSettings, + project: { + ...(currentSettings.project || {}), + ...(project || {}), + details: { + ...((currentSettings.project && + currentSettings.project.details) || + {}), + ...((project && project.details) || {}), + }, + }, + }; + + await usersRepository.update(user, { ui_settings: newSettings }); + + return { success: true, ui_settings: newSettings }; + } +} + +module.exports = new UsersService(); diff --git a/backend/services/userService.js b/backend/modules/users/userService.js similarity index 97% rename from backend/services/userService.js rename to backend/modules/users/userService.js index 8b08a07..2c1b44b 100644 --- a/backend/services/userService.js +++ b/backend/modules/users/userService.js @@ -1,6 +1,6 @@ 'use strict'; -const { User } = require('../models'); +const { User } = require('../../models'); const bcrypt = require('bcrypt'); const _ = require('lodash'); diff --git a/backend/modules/users/validation.js b/backend/modules/users/validation.js new file mode 100644 index 0000000..da071fa --- /dev/null +++ b/backend/modules/users/validation.js @@ -0,0 +1,107 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); + +const VALID_FREQUENCIES = [ + 'daily', + 'weekdays', + 'weekly', + '1h', + '2h', + '4h', + '8h', + '12h', +]; + +/** + * Validate first day of week. + */ +function validateFirstDayOfWeek(value) { + if (value === undefined) return; + if (typeof value !== 'number' || value < 0 || value > 6) { + throw new ValidationError( + 'First day of week must be a number between 0 (Sunday) and 6 (Saturday)', + 'first_day_of_week' + ); + } +} + +/** + * Validate password. + */ +function validatePassword(password, field = 'password') { + if (password && password.length < 6) { + throw new ValidationError( + 'Password must be at least 6 characters', + field + ); + } +} + +/** + * Validate task summary frequency. + */ +function validateFrequency(frequency) { + if (!frequency) { + throw new ValidationError('Frequency is required.'); + } + if (!VALID_FREQUENCIES.includes(frequency)) { + throw new ValidationError('Invalid frequency value.'); + } + return frequency; +} + +/** + * Validate API key ID. + */ +function validateApiKeyId(id) { + const tokenId = parseInt(id, 10); + if (Number.isNaN(tokenId)) { + throw new ValidationError('Invalid API key id.'); + } + return tokenId; +} + +/** + * Validate API key name. + */ +function validateApiKeyName(name) { + if (!name || !name.trim()) { + throw new ValidationError('API key name is required.'); + } + return name.trim(); +} + +/** + * Validate expires_at date. + */ +function validateExpiresAt(expires_at) { + if (!expires_at) return null; + const parsedDate = new Date(expires_at); + if (Number.isNaN(parsedDate.getTime())) { + throw new ValidationError('expires_at must be a valid date.'); + } + return parsedDate; +} + +/** + * Validate sidebar settings. + */ +function validateSidebarSettings(body) { + const { pinnedViewsOrder } = body; + if (!Array.isArray(pinnedViewsOrder)) { + throw new ValidationError('pinnedViewsOrder must be an array'); + } + return { pinnedViewsOrder }; +} + +module.exports = { + VALID_FREQUENCIES, + validateFirstDayOfWeek, + validatePassword, + validateFrequency, + validateApiKeyId, + validateApiKeyName, + validateExpiresAt, + validateSidebarSettings, +}; diff --git a/backend/modules/views/controller.js b/backend/modules/views/controller.js new file mode 100644 index 0000000..aac5428 --- /dev/null +++ b/backend/modules/views/controller.js @@ -0,0 +1,71 @@ +'use strict'; + +const viewsService = require('./service'); + +const viewsController = { + async getAll(req, res, next) { + try { + const views = await viewsService.getAll(req.currentUser.id); + res.json(views); + } catch (error) { + next(error); + } + }, + + async getPinned(req, res, next) { + try { + const views = await viewsService.getPinned(req.currentUser.id); + res.json(views); + } catch (error) { + next(error); + } + }, + + async getOne(req, res, next) { + try { + const uid = decodeURIComponent(req.params.identifier); + const view = await viewsService.getByUid(req.currentUser.id, uid); + res.json(view); + } catch (error) { + next(error); + } + }, + + async create(req, res, next) { + try { + const view = await viewsService.create( + req.currentUser.id, + req.body + ); + res.status(201).json(view); + } catch (error) { + next(error); + } + }, + + async update(req, res, next) { + try { + const uid = decodeURIComponent(req.params.identifier); + const view = await viewsService.update( + req.currentUser.id, + uid, + req.body + ); + res.json(view); + } catch (error) { + next(error); + } + }, + + async delete(req, res, next) { + try { + const uid = decodeURIComponent(req.params.identifier); + const result = await viewsService.delete(req.currentUser.id, uid); + res.json(result); + } catch (error) { + next(error); + } + }, +}; + +module.exports = viewsController; diff --git a/backend/modules/views/index.js b/backend/modules/views/index.js new file mode 100644 index 0000000..766b56c --- /dev/null +++ b/backend/modules/views/index.js @@ -0,0 +1,13 @@ +'use strict'; + +const routes = require('./routes'); +const viewsService = require('./service'); +const viewsRepository = require('./repository'); +const { validateName } = require('./validation'); + +module.exports = { + routes, + viewsService, + viewsRepository, + validateName, +}; diff --git a/backend/modules/views/repository.js b/backend/modules/views/repository.js new file mode 100644 index 0000000..0ee76fd --- /dev/null +++ b/backend/modules/views/repository.js @@ -0,0 +1,39 @@ +'use strict'; + +const BaseRepository = require('../../shared/database/BaseRepository'); +const { View } = require('../../models'); + +class ViewsRepository extends BaseRepository { + constructor() { + super(View); + } + + async findAllByUser(userId) { + return this.model.findAll({ + where: { user_id: userId }, + order: [ + ['is_pinned', 'DESC'], + ['created_at', 'DESC'], + ], + }); + } + + async findPinnedByUser(userId) { + return this.model.findAll({ + where: { user_id: userId, is_pinned: true }, + order: [['created_at', 'DESC']], + }); + } + + async findByUidAndUser(uid, userId) { + return this.model.findOne({ + where: { uid, user_id: userId }, + }); + } + + async createForUser(userId, data) { + return this.model.create({ ...data, user_id: userId }); + } +} + +module.exports = new ViewsRepository(); diff --git a/backend/modules/views/routes.js b/backend/modules/views/routes.js new file mode 100644 index 0000000..476b980 --- /dev/null +++ b/backend/modules/views/routes.js @@ -0,0 +1,14 @@ +'use strict'; + +const express = require('express'); +const router = express.Router(); +const viewsController = require('./controller'); + +router.get('/views', viewsController.getAll); +router.get('/views/pinned', viewsController.getPinned); +router.get('/views/:identifier', viewsController.getOne); +router.post('/views', viewsController.create); +router.patch('/views/:identifier', viewsController.update); +router.delete('/views/:identifier', viewsController.delete); + +module.exports = router; diff --git a/backend/modules/views/service.js b/backend/modules/views/service.js new file mode 100644 index 0000000..1f8a60f --- /dev/null +++ b/backend/modules/views/service.js @@ -0,0 +1,98 @@ +'use strict'; + +const viewsRepository = require('./repository'); +const { validateName } = require('./validation'); +const { NotFoundError } = require('../../shared/errors'); + +class ViewsService { + async getAll(userId) { + return viewsRepository.findAllByUser(userId); + } + + async getPinned(userId) { + return viewsRepository.findPinnedByUser(userId); + } + + async getByUid(userId, uid) { + const view = await viewsRepository.findByUidAndUser(uid, userId); + if (!view) { + throw new NotFoundError('View not found'); + } + return view; + } + + async create(userId, data) { + const { + name, + search_query, + filters, + priority, + due, + defer, + tags, + extras, + recurring, + } = data; + + const validatedName = validateName(name); + + return viewsRepository.createForUser(userId, { + name: validatedName, + search_query: search_query || null, + filters: filters || [], + priority: priority || null, + due: due || null, + defer: defer || null, + tags: tags || [], + extras: extras || [], + recurring: recurring || null, + is_pinned: false, + }); + } + + async update(userId, uid, data) { + const view = await viewsRepository.findByUidAndUser(uid, userId); + if (!view) { + throw new NotFoundError('View not found'); + } + + const { + name, + search_query, + filters, + priority, + due, + defer, + tags, + extras, + recurring, + is_pinned, + } = data; + + const updates = {}; + if (name !== undefined) updates.name = name.trim(); + if (search_query !== undefined) updates.search_query = search_query; + if (filters !== undefined) updates.filters = filters; + if (priority !== undefined) updates.priority = priority; + if (due !== undefined) updates.due = due; + if (defer !== undefined) updates.defer = defer; + if (tags !== undefined) updates.tags = tags; + if (extras !== undefined) updates.extras = extras; + if (recurring !== undefined) updates.recurring = recurring; + if (is_pinned !== undefined) updates.is_pinned = is_pinned; + + await viewsRepository.update(view, updates); + return view; + } + + async delete(userId, uid) { + const view = await viewsRepository.findByUidAndUser(uid, userId); + if (!view) { + throw new NotFoundError('View not found'); + } + await viewsRepository.destroy(view); + return { message: 'View successfully deleted' }; + } +} + +module.exports = new ViewsService(); diff --git a/backend/modules/views/validation.js b/backend/modules/views/validation.js new file mode 100644 index 0000000..a63ab8f --- /dev/null +++ b/backend/modules/views/validation.js @@ -0,0 +1,12 @@ +'use strict'; + +const { ValidationError } = require('../../shared/errors'); + +function validateName(name) { + if (!name || name.trim() === '') { + throw new ValidationError('View name is required'); + } + return name.trim(); +} + +module.exports = { validateName }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js deleted file mode 100644 index 81472bd..0000000 --- a/backend/routes/admin.js +++ /dev/null @@ -1,359 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { - Role, - User, - Area, - Project, - Task, - Tag, - Note, - InboxItem, - TaskEvent, - Action, - Permission, - View, - ApiToken, - Notification, - RecurringCompletion, -} = require('../models'); -const { isAdmin } = require('../services/rolesService'); -const { logError } = require('../services/logService'); - -// POST /api/admin/set-admin-role -// Body: { user_id: number, is_admin: boolean } -router.post('/admin/set-admin-role', async (req, res) => { - try { - const requesterId = req.currentUser?.id || req.session?.userId; - if (!requesterId) - return res.status(401).json({ error: 'Authentication required' }); - - // Fetch user to get uid for isAdmin check - const requester = await User.findByPk(requesterId, { - attributes: ['uid'], - }); - if (!requester) - return res.status(401).json({ error: 'Authentication required' }); - - // Allow if requester is already admin OR if there are no roles yet (bootstrap) - const requesterIsAdmin = await isAdmin(requester.uid); - const existingRolesCount = await Role.count(); - if (!requesterIsAdmin && existingRolesCount > 0) { - return res.status(403).json({ error: 'Forbidden' }); - } - - const { user_id, is_admin } = req.body; - if (!user_id || typeof is_admin !== 'boolean') { - return res - .status(400) - .json({ error: 'user_id and is_admin are required' }); - } - - const user = await User.findByPk(user_id); - if (!user) return res.status(400).json({ error: 'Invalid user_id' }); - - const [role] = await Role.findOrCreate({ - where: { user_id }, - defaults: { user_id, is_admin }, - }); - if (role.is_admin !== is_admin) { - role.is_admin = is_admin; - await role.save(); - } - res.json({ user_id, is_admin: role.is_admin }); - } catch (err) { - logError('Error setting admin role:', err); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -module.exports = router; - -// --- Admin Users Management --- -// NOTE: app.js already mounts this router under requireAuth - -// Middleware to ensure admin access -async function requireAdmin(req, res, next) { - try { - const requesterId = req.currentUser?.id || req.session?.userId; - if (!requesterId) - return res.status(401).json({ error: 'Authentication required' }); - - // Fetch user to get uid for isAdmin check - const user = await User.findByPk(requesterId, { attributes: ['uid'] }); - if (!user) - return res.status(401).json({ error: 'Authentication required' }); - - const admin = await isAdmin(user.uid); - if (!admin) return res.status(403).json({ error: 'Forbidden' }); - next(); - } catch (err) { - next(err); - } -} - -// GET /api/admin/users - list users with role and creation date -router.get('/admin/users', requireAdmin, async (req, res) => { - try { - const users = await User.findAll({ - attributes: ['id', 'email', 'name', 'surname', 'created_at'], - }); - // Fetch roles in bulk - const roles = await Role.findAll({ - attributes: ['user_id', 'is_admin'], - }); - const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin])); - const result = users.map((u) => ({ - id: u.id, - email: u.email, - name: u.name, - surname: u.surname, - created_at: u.created_at, - role: userIdToRole.get(u.id) ? 'admin' : 'user', - })); - res.json(result); - } catch (err) { - logError('Error listing users:', err); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// POST /api/admin/users - create a user (default role: user) -router.post('/admin/users', requireAdmin, async (req, res) => { - try { - const { email, password, name, surname, role } = req.body || {}; - if (!email || !password) { - return res - .status(400) - .json({ error: 'Email and password are required' }); - } - // Very basic validation consistent with login rules - if (typeof email !== 'string' || !email.includes('@')) { - return res.status(400).json({ error: 'Invalid email' }); - } - if (typeof password !== 'string' || password.length < 6) { - return res - .status(400) - .json({ error: 'Password must be at least 6 characters' }); - } - // Create user; model hook will hash password - const userData = { email, password }; - if (name) userData.name = name; - if (surname) userData.surname = surname; - const user = await User.create(userData); - // Optionally assign admin role if requested and allowed - const makeAdmin = role === 'admin'; - if (makeAdmin) { - // Find or create role, and ensure is_admin is true - const [userRole, roleCreated] = await Role.findOrCreate({ - where: { user_id: user.id }, - defaults: { user_id: user.id, is_admin: true }, - }); - - // Update to admin if role exists but is not admin - if (!roleCreated && !userRole.is_admin) { - userRole.is_admin = true; - await userRole.save(); - } - } - res.status(201).json({ - id: user.id, - email: user.email, - name: user.name, - surname: user.surname, - created_at: user.created_at, - role: makeAdmin ? 'admin' : 'user', - }); - } catch (err) { - logError('Error creating user:', err); - // Unique constraint - if (err?.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ error: 'Email already exists' }); - } - res.status(400).json({ - error: 'There was a problem creating the user.', - }); - } -}); - -// PUT /api/admin/users/:id - update a user -router.put('/admin/users/:id', requireAdmin, async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - if (!Number.isFinite(id)) - return res.status(400).json({ error: 'Invalid user id' }); - - const user = await User.findByPk(id); - if (!user) return res.status(404).json({ error: 'User not found' }); - - const { email, password, name, surname, role } = req.body || {}; - - // Update email if provided - if (email !== undefined && email !== null) { - if (typeof email !== 'string' || !email.includes('@')) { - return res.status(400).json({ error: 'Invalid email' }); - } - user.email = email; - } - - // Update password if provided - if (password && password.trim() !== '') { - if (typeof password !== 'string' || password.length < 6) { - return res - .status(400) - .json({ error: 'Password must be at least 6 characters' }); - } - user.password = password; - } - - // Update name and surname - handle empty strings properly - if (name !== undefined) user.name = name || null; - if (surname !== undefined) user.surname = surname || null; - - await user.save(); - - // Update role if provided - if (role !== undefined) { - const makeAdmin = role === 'admin'; - const [userRole] = await Role.findOrCreate({ - where: { user_id: user.id }, - defaults: { user_id: user.id, is_admin: makeAdmin }, - }); - if (userRole.is_admin !== makeAdmin) { - userRole.is_admin = makeAdmin; - await userRole.save(); - } - } - - // Fetch updated role - const userRole = await Role.findOne({ where: { user_id: user.id } }); - - res.json({ - id: user.id, - email: user.email, - name: user.name, - surname: user.surname, - created_at: user.created_at, - role: userRole?.is_admin ? 'admin' : 'user', - }); - } catch (err) { - logError('Error updating user:', err); - // Unique constraint - if (err?.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ error: 'Email already exists' }); - } - res.status(400).json({ - error: 'There was a problem updating the user.', - }); - } -}); - -// DELETE /api/admin/users/:id - delete a user, prevent self-delete -router.delete('/admin/users/:id', requireAdmin, async (req, res) => { - const { sequelize } = require('../models'); - const transaction = await sequelize.transaction(); - - try { - const id = parseInt(req.params.id, 10); - const requesterId = req.currentUser?.id || req.session?.userId; - if (!Number.isFinite(id)) { - await transaction.rollback(); - return res.status(400).json({ error: 'Invalid user id' }); - } - if (id === requesterId) { - await transaction.rollback(); - return res - .status(400) - .json({ error: 'Cannot delete your own account' }); - } - - const user = await User.findByPk(id, { transaction }); - if (!user) { - await transaction.rollback(); - return res.status(404).json({ error: 'User not found' }); - } - - // Prevent deleting the last remaining admin - const targetRole = await Role.findOne({ - where: { user_id: id }, - transaction, - }); - if (targetRole?.is_admin) { - const adminCount = await Role.count({ - where: { is_admin: true }, - transaction, - }); - if (adminCount <= 1) { - await transaction.rollback(); - return res - .status(400) - .json({ error: 'Cannot delete the last remaining admin' }); - } - } - - await TaskEvent.destroy({ where: { user_id: id }, transaction }); - - const userTasks = await Task.findAll({ - where: { user_id: id }, - attributes: ['id'], - transaction, - }); - const taskIds = userTasks.map((t) => t.id); - if (taskIds.length > 0) { - await RecurringCompletion.destroy({ - where: { task_id: taskIds }, - transaction, - }); - } - - await Task.destroy({ where: { user_id: id }, transaction }); - await Note.destroy({ where: { user_id: id }, transaction }); - await Project.destroy({ where: { user_id: id }, transaction }); - await Area.destroy({ where: { user_id: id }, transaction }); - await Tag.destroy({ where: { user_id: id }, transaction }); - await InboxItem.destroy({ where: { user_id: id }, transaction }); - await View.destroy({ where: { user_id: id }, transaction }); - await Notification.destroy({ where: { user_id: id }, transaction }); - await ApiToken.destroy({ where: { user_id: id }, transaction }); - await Permission.destroy({ where: { user_id: id }, transaction }); - await Permission.destroy({ - where: { granted_by_user_id: id }, - transaction, - }); - await Action.destroy({ where: { actor_user_id: id }, transaction }); - await Action.destroy({ where: { target_user_id: id }, transaction }); - await Role.destroy({ where: { user_id: id }, transaction }); - await user.destroy({ transaction }); - - await transaction.commit(); - res.status(204).send(); - } catch (err) { - await transaction.rollback(); - logError('Error deleting user:', err); - res.status(400).json({ - error: 'There was a problem deleting the user.', - }); - } -}); - -// POST /api/admin/toggle-registration - toggle registration setting -router.post('/admin/toggle-registration', requireAdmin, async (req, res) => { - try { - const { enabled } = req.body; - if (typeof enabled !== 'boolean') { - return res - .status(400) - .json({ error: 'enabled must be a boolean value' }); - } - - const { - setRegistrationEnabled, - } = require('../services/registrationService'); - await setRegistrationEnabled(enabled); - - res.json({ enabled }); - } catch (err) { - logError('Error toggling registration:', err); - res.status(500).json({ error: 'Internal server error' }); - } -}); diff --git a/backend/routes/areas.js b/backend/routes/areas.js deleted file mode 100644 index e701808..0000000 --- a/backend/routes/areas.js +++ /dev/null @@ -1,141 +0,0 @@ -const express = require('express'); -const { Area } = require('../models'); -const { isValidUid } = require('../utils/slug-utils'); -const { logError } = require('../services/logService'); -const _ = require('lodash'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); - -router.get('/areas', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) - return res.status(401).json({ error: 'Authentication required' }); - const areas = await Area.findAll({ - where: { user_id: userId }, - attributes: ['id', 'uid', 'name', 'description'], - order: [['name', 'ASC']], - }); - - res.json(areas); - } catch (error) { - logError('Error fetching areas:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/areas/:uid', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) - return res.status(401).json({ error: 'Authentication required' }); - if (!isValidUid(req.params.uid)) - return res.status(400).json({ error: 'Invalid UID' }); - const area = await Area.findOne({ - where: { uid: req.params.uid, user_id: userId }, - attributes: ['uid', 'name', 'description'], - }); - - if (_.isEmpty(area)) { - return res.status(404).json({ - error: "Area not found or doesn't belong to the current user.", - }); - } - - res.json(area); - } catch (error) { - logError('Error fetching area:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post('/areas', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) - return res.status(401).json({ error: 'Authentication required' }); - const { name, description } = req.body; - - if (!name || _.isEmpty(name.trim())) { - return res.status(400).json({ error: 'Area name is required.' }); - } - - const area = await Area.create({ - name: name.trim(), - description: description || '', - user_id: userId, - }); - - res.status(201).json(_.pick(area, ['uid', 'name', 'description'])); - } catch (error) { - logError('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], - }); - } -}); - -router.patch('/areas/:uid', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) - return res.status(401).json({ error: 'Authentication required' }); - if (!isValidUid(req.params.uid)) - return res.status(400).json({ error: 'Invalid UID' }); - const area = await Area.findOne({ - where: { uid: req.params.uid, user_id: 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(_.pick(area, ['uid', 'name', 'description'])); - } catch (error) { - logError('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], - }); - } -}); - -router.delete('/areas/:uid', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) - return res.status(401).json({ error: 'Authentication required' }); - if (!isValidUid(req.params.uid)) - return res.status(400).json({ error: 'Invalid UID' }); - - const area = await Area.findOne({ - where: { uid: req.params.uid, user_id: userId }, - }); - - if (!area) { - return res.status(404).json({ error: 'Area not found.' }); - } - - await area.destroy(); - return res.status(204).send(); - } catch (error) { - logError('Error deleting area:', error); - res.status(400).json({ - error: 'There was a problem deleting the area.', - }); - } -}); - -module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js deleted file mode 100644 index fb2b298..0000000 --- a/backend/routes/auth.js +++ /dev/null @@ -1,234 +0,0 @@ -const express = require('express'); -const { User } = require('../models'); -const { isAdmin } = require('../services/rolesService'); -const { logError } = require('../services/logService'); -const { getConfig } = require('../config/config'); -const { - isRegistrationEnabled, - createUnverifiedUser, - sendVerificationEmail, - verifyUserEmail, -} = require('../services/registrationService'); -const packageJson = require('../../package.json'); -const { authLimiter } = require('../middleware/rateLimiter'); -const router = express.Router(); - -router.get('/version', (req, res) => { - res.json({ version: packageJson.version }); -}); - -// Get registration status -router.get('/registration-status', async (req, res) => { - res.json({ enabled: await isRegistrationEnabled() }); -}); - -// Register new user -router.post('/register', async (req, res) => { - const { sequelize } = require('../models'); - const transaction = await sequelize.transaction(); - - try { - if (!(await isRegistrationEnabled())) { - await transaction.rollback(); - return res - .status(404) - .json({ error: 'Registration is not enabled' }); - } - - const { email, password } = req.body; - - if (!email || !password) { - await transaction.rollback(); - return res - .status(400) - .json({ error: 'Email and password are required' }); - } - - const { user, verificationToken } = await createUnverifiedUser( - email, - password, - transaction - ); - - const emailResult = await sendVerificationEmail( - user, - verificationToken - ); - - if (!emailResult.success) { - await transaction.rollback(); - logError( - new Error(emailResult.reason), - 'Email sending failed during registration, rolling back user creation' - ); - return res.status(500).json({ - error: 'Failed to send verification email. Please try again later.', - }); - } - - await transaction.commit(); - - res.status(201).json({ - message: - 'Registration successful. Please check your email to verify your account.', - }); - } catch (error) { - await transaction.rollback(); - - if (error.message === 'Email already registered') { - return res.status(400).json({ error: error.message }); - } - if ( - error.message === 'Invalid email format' || - error.message === 'Password must be at least 6 characters long' - ) { - return res.status(400).json({ error: error.message }); - } - logError('Registration error:', error); - res.status(500).json({ - error: 'Registration failed. Please try again.', - }); - } -}); - -// Verify email -router.get('/verify-email', async (req, res) => { - try { - const { token } = req.query; - - if (!token) { - return res - .status(400) - .json({ error: 'Verification token is required' }); - } - - await verifyUserEmail(token); - - const config = getConfig(); - res.redirect(`${config.frontendUrl}/login?verified=true`); - } catch (error) { - const config = getConfig(); - let errorParam = 'invalid'; - - if (error.message === 'Email already verified') { - errorParam = 'already_verified'; - } else if (error.message === 'Verification token has expired') { - errorParam = 'expired'; - } - - logError('Email verification error:', error); - res.redirect( - `${config.frontendUrl}/login?verified=false&error=${errorParam}` - ); - } -}); - -router.get('/current_user', async (req, res) => { - try { - if (req.session && req.session.userId) { - const user = await User.findByPk(req.session.userId, { - attributes: [ - 'uid', - 'email', - 'name', - 'surname', - 'language', - 'appearance', - 'timezone', - 'avatar_image', - ], - }); - if (user) { - const admin = await isAdmin(user.uid); - return res.json({ - user: { - uid: user.uid, - email: user.email, - name: user.name, - surname: user.surname, - language: user.language, - appearance: user.appearance, - timezone: user.timezone, - avatar_image: user.avatar_image, - is_admin: admin, - }, - }); - } - } - - res.json({ user: null }); - } catch (error) { - logError('Error fetching current user:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post('/login', authLimiter, async (req, res) => { - try { - const { email, password } = req.body; - - 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'] }); - } - - if (!user.email_verified) { - return res.status(403).json({ - error: 'Please verify your email address before logging in.', - email_not_verified: true, - }); - } - - req.session.userId = user.id; - - await new Promise((resolve, reject) => { - req.session.save((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - const admin = await isAdmin(user.uid); - res.json({ - user: { - uid: user.uid, - email: user.email, - name: user.name, - surname: user.surname, - language: user.language, - appearance: user.appearance, - timezone: user.timezone, - avatar_image: user.avatar_image, - is_admin: admin, - }, - }); - } catch (error) { - logError('Login error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/logout', (req, res) => { - req.session.destroy((err) => { - if (err) { - logError('Logout error:', err); - return res.status(500).json({ error: 'Could not log out' }); - } - - res.json({ message: 'Logged out successfully' }); - }); -}); - -module.exports = router; diff --git a/backend/routes/backup.js b/backend/routes/backup.js deleted file mode 100644 index a5a1977..0000000 --- a/backend/routes/backup.js +++ /dev/null @@ -1,363 +0,0 @@ -const express = require('express'); -const { logError } = require('../services/logService'); -const { - exportUserData, - importUserData, - validateBackupData, - saveBackup, - listBackups, - getBackup, - deleteBackup, - getBackupsDirectory, - checkVersionCompatibility, -} = require('../services/backupService'); -const { Backup } = require('../models'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); -const multer = require('multer'); -const zlib = require('zlib'); -const { promisify } = require('util'); -const path = require('path'); -const fs = require('fs').promises; - -const gunzip = promisify(zlib.gunzip); -const gzip = promisify(zlib.gzip); - -const checkBackupsEnabled = (req, res, next) => { - const backupsEnabled = process.env.FF_ENABLE_BACKUPS === 'true'; - if (!backupsEnabled) { - return res.status(403).json({ - error: 'Backups feature is disabled', - message: - 'The backups feature is currently disabled. Please contact your administrator.', - }); - } - next(); -}; - -router.use(checkBackupsEnabled); - -async function parseUploadedBackup(fileBuffer, filename) { - let backupJson; - - const isGzipped = - filename.toLowerCase().endsWith('.gz') || - (fileBuffer[0] === 0x1f && fileBuffer[1] === 0x8b); - - if (isGzipped) { - const decompressed = await gunzip(fileBuffer); - backupJson = decompressed.toString('utf8'); - } else { - backupJson = fileBuffer.toString('utf8'); - } - - return JSON.parse(backupJson); -} -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: 100 * 1024 * 1024, - }, - fileFilter: (req, file, cb) => { - const allowedMimes = [ - 'application/json', - 'application/gzip', - 'application/x-gzip', - ]; - const fileExt = file.originalname.toLowerCase(); - - if ( - allowedMimes.includes(file.mimetype) || - fileExt.endsWith('.json') || - fileExt.endsWith('.gz') - ) { - cb(null, true); - } else { - cb(new Error('Only JSON and gzip files are allowed'), false); - } - }, -}); -router.post('/export', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const backupData = await exportUserData(userId); - - const backup = await saveBackup(userId, backupData); - res.json({ - success: true, - message: 'Backup created successfully', - backup: { - uid: backup.uid, - file_size: backup.file_size, - item_counts: backup.item_counts, - created_at: backup.created_at, - }, - }); - } catch (error) { - logError('Error exporting user data:', error); - res.status(500).json({ - error: 'Failed to export data', - message: error.message, - }); - } -}); - -router.post('/import', upload.single('backup'), async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - if (!req.file) { - return res.status(400).json({ error: 'No backup file provided' }); - } - - let backupData; - try { - backupData = await parseUploadedBackup( - req.file.buffer, - req.file.originalname - ); - } catch (parseError) { - return res.status(400).json({ - error: 'Invalid backup file', - message: parseError.message, - }); - } - - const validation = validateBackupData(backupData); - if (!validation.valid) { - return res.status(400).json({ - error: 'Invalid backup data', - errors: validation.errors, - }); - } - - const versionCheck = checkVersionCompatibility(backupData.version); - if (!versionCheck.compatible) { - return res.status(400).json({ - error: 'Version incompatible', - message: versionCheck.message, - backupVersion: backupData.version, - }); - } - - const options = { - merge: req.body.merge !== 'false', - }; - - const stats = await importUserData(userId, backupData, options); - - res.json({ - success: true, - message: 'Backup imported successfully', - stats, - }); - } catch (error) { - logError('Error importing user data:', error); - res.status(500).json({ - error: 'Failed to import data', - message: error.message, - }); - } -}); - -router.post('/validate', upload.single('backup'), async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - if (!req.file) { - return res.status(400).json({ error: 'No backup file provided' }); - } - - let backupData; - try { - backupData = await parseUploadedBackup( - req.file.buffer, - req.file.originalname - ); - } catch (parseError) { - return res.status(400).json({ - valid: false, - error: 'Invalid backup file', - message: parseError.message, - }); - } - - const validation = validateBackupData(backupData); - - if (!validation.valid) { - return res.status(400).json({ - valid: false, - errors: validation.errors, - }); - } - - const versionCheck = checkVersionCompatibility(backupData.version); - if (!versionCheck.compatible) { - return res.status(400).json({ - valid: false, - versionIncompatible: true, - message: versionCheck.message, - backupVersion: backupData.version, - }); - } - - const summary = { - areas: backupData.data.areas?.length || 0, - projects: backupData.data.projects?.length || 0, - tasks: backupData.data.tasks?.length || 0, - tags: backupData.data.tags?.length || 0, - notes: backupData.data.notes?.length || 0, - inbox_items: backupData.data.inbox_items?.length || 0, - views: backupData.data.views?.length || 0, - }; - - res.json({ - valid: true, - message: 'Backup file is valid', - version: backupData.version, - exported_at: backupData.exported_at, - summary, - }); - } catch (error) { - logError('Error validating backup file:', error); - res.status(500).json({ - valid: false, - error: 'Failed to validate backup file', - message: error.message, - }); - } -}); - -router.get('/list', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const backups = await listBackups(userId, 5); - - res.json({ - success: true, - backups, - }); - } catch (error) { - logError('Error listing backups:', error); - res.status(500).json({ - error: 'Failed to list backups', - message: error.message, - }); - } -}); - -router.get('/:uid/download', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const backup = await Backup.findOne({ - where: { uid: req.params.uid, user_id: userId }, - }); - - if (!backup) { - return res.status(404).json({ error: 'Backup not found' }); - } - - const backupsDir = await getBackupsDirectory(); - const filePath = path.join(backupsDir, backup.file_path); - - const fileBuffer = await fs.readFile(filePath); - const isCompressed = backup.file_path.endsWith('.gz'); - const filename = `tududi-backup-${new Date().toISOString().split('T')[0]}${isCompressed ? '.json.gz' : '.json'}`; - const contentType = isCompressed - ? 'application/gzip' - : 'application/json'; - res.setHeader('Content-Type', contentType); - res.setHeader( - 'Content-Disposition', - `attachment; filename="${filename}"` - ); - res.setHeader('Content-Length', fileBuffer.length); - - res.send(fileBuffer); - } catch (error) { - logError('Error downloading backup:', error); - res.status(500).json({ - error: 'Failed to download backup', - message: error.message, - }); - } -}); - -router.post('/:uid/restore', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const backupData = await getBackup(userId, req.params.uid); - const versionCheck = checkVersionCompatibility(backupData.version); - if (!versionCheck.compatible) { - return res.status(400).json({ - error: 'Version incompatible', - message: versionCheck.message, - backupVersion: backupData.version, - }); - } - - const options = { - merge: req.body.merge !== false, - }; - - const stats = await importUserData(userId, backupData, options); - - res.json({ - success: true, - message: 'Backup restored successfully', - stats, - }); - } catch (error) { - logError('Error restoring backup:', error); - res.status(500).json({ - error: 'Failed to restore backup', - message: error.message, - }); - } -}); - -router.delete('/:uid', async (req, res) => { - try { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - - await deleteBackup(userId, req.params.uid); - - res.json({ - success: true, - message: 'Backup deleted successfully', - }); - } catch (error) { - logError('Error deleting backup:', error); - res.status(500).json({ - error: 'Failed to delete backup', - message: error.message, - }); - } -}); - -module.exports = router; diff --git a/backend/routes/feature-flags.js b/backend/routes/feature-flags.js deleted file mode 100644 index 08611ab..0000000 --- a/backend/routes/feature-flags.js +++ /dev/null @@ -1,22 +0,0 @@ -const express = require('express'); -const router = express.Router(); - -router.get('/feature-flags', (req, res) => { - try { - const featureFlags = { - backups: process.env.FF_ENABLE_BACKUPS === 'true', - calendar: process.env.FF_ENABLE_CALENDAR === 'true', - habits: process.env.FF_ENABLE_HABITS === 'true', - }; - - res.json({ featureFlags }); - } catch (error) { - console.error('Error fetching feature flags:', error); - res.status(500).json({ - error: 'Failed to fetch feature flags', - message: error.message, - }); - } -}); - -module.exports = router; diff --git a/backend/routes/habits.js b/backend/routes/habits.js deleted file mode 100644 index 4417734..0000000 --- a/backend/routes/habits.js +++ /dev/null @@ -1,209 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { Task, RecurringCompletion } = require('../models'); -const habitService = require('../services/habitService'); -const { requireAuth } = require('../middleware/auth'); - -// GET /api/habits - List all habits for current user -router.get('/', requireAuth, async (req, res) => { - try { - const habits = await Task.findAll({ - where: { - user_id: req.currentUser.id, - habit_mode: true, - status: { [require('sequelize').Op.ne]: 3 }, // Exclude archived - }, - order: [['created_at', 'DESC']], - }); - - res.json({ habits }); - } catch (error) { - console.error('Error fetching habits:', error); - res.status(500).json({ error: 'Failed to fetch habits' }); - } -}); - -// POST /api/habits - Create new habit -router.post('/', requireAuth, async (req, res) => { - try { - const habitData = { - ...req.body, - user_id: req.currentUser.id, - habit_mode: true, - status: 0, // NOT_STARTED - }; - - const habit = await Task.create(habitData); - res.status(201).json({ habit }); - } catch (error) { - console.error('Error creating habit:', error); - res.status(500).json({ error: 'Failed to create habit' }); - } -}); - -// POST /api/habits/:uid/complete - Log completion -router.post('/:uid/complete', requireAuth, async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - const { completed_at } = req.body; - const result = await habitService.logCompletion( - habit, - completed_at ? new Date(completed_at) : new Date() - ); - - res.json(result); - } catch (error) { - console.error('Error logging completion:', error); - res.status(500).json({ error: 'Failed to log completion' }); - } -}); - -// GET /api/habits/:uid/completions - Get habit completions -router.get('/:uid/completions', requireAuth, async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - const { start_date, end_date } = req.query; - const startDate = start_date - ? new Date(start_date) - : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const endDate = end_date ? new Date(end_date) : new Date(); - - const completions = await RecurringCompletion.findAll({ - where: { - task_id: habit.id, - skipped: false, - completed_at: { - [require('sequelize').Op.between]: [startDate, endDate], - }, - }, - order: [['completed_at', 'DESC']], - }); - - res.json({ completions }); - } catch (error) { - console.error('Error fetching completions:', error); - res.status(500).json({ error: 'Failed to fetch completions' }); - } -}); - -// DELETE /api/habits/:uid/completions/:completionId - Delete a specific completion -router.delete( - '/:uid/completions/:completionId', - requireAuth, - async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - const completion = await RecurringCompletion.findOne({ - where: { - id: req.params.completionId, - task_id: habit.id, - }, - }); - - if (!completion) { - return res.status(404).json({ error: 'Completion not found' }); - } - - await completion.destroy(); - - // Recalculate streaks after deletion - const updates = await habitService.recalculateStreaks(habit); - await habit.update(updates); - - res.json({ message: 'Completion deleted', task: habit }); - } catch (error) { - console.error('Error deleting completion:', error); - res.status(500).json({ error: 'Failed to delete completion' }); - } - } -); - -// GET /api/habits/:uid/stats - Get habit statistics -router.get('/:uid/stats', requireAuth, async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - const { start_date, end_date } = req.query; - const startDate = start_date - ? new Date(start_date) - : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const endDate = end_date ? new Date(end_date) : new Date(); - - const stats = await habitService.getHabitStats( - habit, - startDate, - endDate - ); - res.json(stats); - } catch (error) { - console.error('Error fetching stats:', error); - res.status(500).json({ error: 'Failed to fetch stats' }); - } -}); - -// PUT /api/habits/:uid - Update habit -router.put('/:uid', requireAuth, async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - await habit.update(req.body); - res.json({ habit }); - } catch (error) { - console.error('Error updating habit:', error); - res.status(500).json({ error: 'Failed to update habit' }); - } -}); - -// DELETE /api/habits/:uid - Delete habit -router.delete('/:uid', requireAuth, async (req, res) => { - try { - const habit = await Task.findOne({ - where: { uid: req.params.uid, user_id: req.currentUser.id }, - }); - - if (!habit || !habit.habit_mode) { - return res.status(404).json({ error: 'Habit not found' }); - } - - await habit.destroy(); - res.json({ message: 'Habit deleted' }); - } catch (error) { - console.error('Error deleting habit:', error); - res.status(500).json({ error: 'Failed to delete habit' }); - } -}); - -module.exports = router; diff --git a/backend/routes/inbox.js b/backend/routes/inbox.js deleted file mode 100644 index 9e59e7d..0000000 --- a/backend/routes/inbox.js +++ /dev/null @@ -1,317 +0,0 @@ -const express = require('express'); -const { InboxItem } = require('../models'); -const { processInboxItem } = require('../services/inboxProcessingService'); -const { isValidUid } = require('../utils/slug-utils'); -const _ = require('lodash'); -const { logError } = require('../services/logService'); -const { getAuthenticatedUserId } = require('../utils/request-utils'); -const router = express.Router(); - -const TITLE_MAX_LENGTH = 120; - -const buildTitleFromContent = (text) => { - const normalized = text.trim(); - if (normalized.length <= TITLE_MAX_LENGTH) { - return normalized; - } - return `${normalized.slice(0, TITLE_MAX_LENGTH).trim()}...`; -}; - -const getUserIdOrUnauthorized = (req, res) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - res.status(401).json({ error: 'Authentication required' }); - return null; - } - return userId; -}; - -router.get('/inbox', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - // Check if pagination parameters are provided - const hasPagination = - !_.isEmpty(req.query.limit) || !_.isEmpty(req.query.offset); - - if (hasPagination) { - // Parse pagination parameters - const limit = parseInt(req.query.limit, 10) || 20; // Default to 20 items - const offset = parseInt(req.query.offset, 10) || 0; - - // Get total count for pagination info - const totalCount = await InboxItem.count({ - where: { - user_id: userId, - status: 'added', - }, - }); - - const items = await InboxItem.findAll({ - where: { - user_id: userId, - status: 'added', - }, - order: [['created_at', 'DESC']], - limit: limit, - offset: offset, - }); - - res.json({ - items: items, - pagination: { - total: totalCount, - limit: limit, - offset: offset, - hasMore: offset + items.length < totalCount, - }, - }); - } else { - // Return simple array for backward compatibility (used by tests) - const items = await InboxItem.findAll({ - where: { - user_id: userId, - status: 'added', - }, - order: [['created_at', 'DESC']], - }); - - res.json(items); - } - } catch (error) { - logError('Error fetching inbox items:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post('/inbox', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const { content, source } = req.body; - - if ( - !content || - typeof content !== 'string' || - _.isEmpty(content.trim()) - ) { - return res.status(400).json({ error: 'Content is required' }); - } - - // Ensure source is never null/undefined - const finalSource = source && source.trim() ? source.trim() : 'manual'; - const normalizedContent = content.trim(); - if (!normalizedContent) { - return res.status(400).json({ error: 'Content cannot be empty' }); - } - const generatedTitle = buildTitleFromContent(normalizedContent); - - const item = await InboxItem.create({ - content: normalizedContent, - title: generatedTitle, - source: finalSource, - user_id: userId, - }); - - res.status(201).json( - _.pick(item, [ - 'uid', - 'title', - 'content', - 'status', - 'source', - 'created_at', - 'updated_at', - ]) - ); - } catch (error) { - logError('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/:uid -router.get('/inbox/:uid', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - if (!isValidUid(req.params.uid)) { - return res.status(400).json({ error: 'Invalid UID' }); - } - - const item = await InboxItem.findOne({ - where: { uid: req.params.uid, user_id: userId }, - attributes: [ - 'uid', - 'title', - 'content', - 'status', - 'source', - 'created_at', - 'updated_at', - ], - }); - - if (_.isEmpty(item)) { - return res.status(404).json({ error: 'Inbox item not found.' }); - } - - res.json(item); - } catch (error) { - logError('Error fetching inbox item:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// PATCH /api/inbox/:uid -router.patch('/inbox/:uid', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - if (!isValidUid(req.params.uid)) { - return res.status(400).json({ error: 'Invalid UID' }); - } - - const item = await InboxItem.findOne({ - where: { uid: req.params.uid, user_id: userId }, - }); - - if (_.isEmpty(item)) { - return res.status(404).json({ error: 'Inbox item not found.' }); - } - - const { content, status } = req.body; - const updateData = {}; - - if (content != null) { - if (typeof content !== 'string') { - return res - .status(400) - .json({ error: 'Content must be a string' }); - } - const normalizedContent = content.trim(); - if (!normalizedContent) { - return res - .status(400) - .json({ error: 'Content cannot be empty' }); - } - updateData.content = normalizedContent; - updateData.title = buildTitleFromContent(normalizedContent); - } - if (status != null) updateData.status = status; - - await item.update(updateData); - res.json( - _.pick(item, [ - 'uid', - 'title', - 'content', - 'status', - 'source', - 'created_at', - 'updated_at', - ]) - ); - } catch (error) { - logError('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/:uid -router.delete('/inbox/:uid', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - if (!isValidUid(req.params.uid)) { - return res.status(400).json({ error: 'Invalid UID' }); - } - - const item = await InboxItem.findOne({ - where: { uid: req.params.uid, user_id: userId }, - }); - - if (_.isEmpty(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) { - logError('Error deleting inbox item:', error); - res.status(400).json({ - error: 'There was a problem deleting the inbox item.', - }); - } -}); - -// PATCH /api/inbox/:uid/process -router.patch('/inbox/:uid/process', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - if (!isValidUid(req.params.uid)) { - return res.status(400).json({ error: 'Invalid UID' }); - } - - const item = await InboxItem.findOne({ - where: { uid: req.params.uid, user_id: userId }, - }); - - if (_.isEmpty(item)) { - return res.status(404).json({ error: 'Inbox item not found.' }); - } - - await item.update({ status: 'processed' }); - res.json( - _.pick(item, [ - 'uid', - 'title', - 'content', - 'status', - 'source', - 'created_at', - 'updated_at', - ]) - ); - } catch (error) { - logError('Error processing inbox item:', error); - res.status(400).json({ - error: 'There was a problem processing the inbox item.', - }); - } -}); - -// POST /api/inbox/analyze-text -router.post('/inbox/analyze-text', async (req, res) => { - try { - const { content } = req.body; - - if (!content || typeof content !== 'string') { - return res - .status(400) - .json({ error: 'Content is required and must be a string' }); - } - - // Process the text using the inbox processing service - const result = processInboxItem(content); - - res.json(result); - } catch (error) { - logError('Error analyzing inbox text:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -module.exports = router; diff --git a/backend/routes/notes.js b/backend/routes/notes.js deleted file mode 100644 index 301bf40..0000000 --- a/backend/routes/notes.js +++ /dev/null @@ -1,421 +0,0 @@ -const express = require('express'); -const { Note, Tag, Project } = require('../models'); -const { extractUidFromSlug, isValidUid } = require('../utils/slug-utils'); -const { validateTagName } = require('../services/tagsService'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); -const { sortTags } = require('./tasks/core/serializers'); - -// Helper function to serialize a note with sorted tags -function serializeNote(note) { - const noteJson = note.toJSON ? note.toJSON() : note; - return { - ...noteJson, - Tags: sortTags(noteJson.Tags), - }; -} - -router.use((req, res, next) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - req.authUserId = userId; - next(); -}); -const permissionsService = require('../services/permissionsService'); -const { hasAccess } = require('../middleware/authorize'); -const _ = require('lodash'); -const { logError } = require('../services/logService'); - -// Helper function to update note tags -async function updateNoteTags(note, tagsArray, userId) { - if (_.isEmpty(tagsArray)) { - await note.setTags([]); - return; - } - - try { - // Validate and filter tag names - const validTagNames = []; - const invalidTags = []; - - for (const name of tagsArray) { - const validation = validateTagName(name); - if (validation.valid) { - // Check for duplicates - if (!validTagNames.includes(validation.name)) { - validTagNames.push(validation.name); - } - } else { - invalidTags.push({ name, error: validation.error }); - } - } - - if (invalidTags.length > 0) { - throw new Error( - `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` - ); - } - - const tags = await Promise.all( - validTagNames.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) { - logError('Failed to update tags:', error.message); - throw error; // Re-throw to handle at route level - } -} - -router.get('/notes', async (req, res) => { - try { - const orderBy = req.query.order_by || 'title:asc'; - const [orderColumn, orderDirection] = orderBy.split(':'); - - const whereClause = await permissionsService.ownershipOrPermissionWhere( - 'note', - req.authUserId - ); - let includeClause = [ - { - model: Tag, - attributes: ['name', 'uid'], - through: { attributes: [] }, - }, - { - model: Project, - required: false, - attributes: ['name', 'uid'], - }, - ]; - - // 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.map(serializeNote)); - } catch (error) { - logError('Error fetching notes:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get( - '/note/:uidSlug', - hasAccess( - 'ro', - 'note', - async (req) => { - const uid = extractUidFromSlug(req.params.uidSlug); - // Check if note exists - return null if it doesn't (triggers 404) - const note = await Note.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return note ? note.uid : null; - }, - { notFoundMessage: 'Note not found.' } - ), - async (req, res) => { - try { - const note = await Note.findOne({ - where: { uid: extractUidFromSlug(req.params.uidSlug) }, - include: [ - { - model: Tag, - attributes: ['name', 'uid'], - through: { attributes: [] }, - }, - { - model: Project, - required: false, - attributes: ['name', 'uid'], - }, - ], - }); - - res.json(serializeNote(note)); - } catch (error) { - logError('Error fetching note:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -router.post('/note', async (req, res) => { - try { - const { title, content, project_uid, project_id, tags, color } = - req.body; - - const noteAttributes = { - title, - content, - user_id: req.authUserId, - }; - - // Add color if provided - if (color !== undefined) { - noteAttributes.color = color; - } - - // Support both project_uid (new) and project_id (legacy) - const projectIdentifier = project_uid || project_id; - - // If project identifier is provided, validate access and assign - if ( - projectIdentifier && - !_.isEmpty(projectIdentifier.toString().trim()) - ) { - let project; - - // Try to find by UID first (new way), then by ID (legacy) - if (project_uid) { - const projectUidValue = project_uid.toString().trim(); - project = await Project.findOne({ - where: { uid: projectUidValue }, - }); - } else { - // Legacy: find by numeric ID - project = await Project.findByPk(project_id); - } - - if (!project) { - return res - .status(404) - .json({ error: 'Note project not found' }); - } - - // Check if user has write access to the project - const projectAccess = await permissionsService.getAccess( - req.authUserId, - 'project', - project.uid - ); - const isOwner = project.user_id === req.authUserId; - const canWrite = - isOwner || projectAccess === 'rw' || projectAccess === 'admin'; - - if (!canWrite) { - return res.status(403).json({ error: 'Forbidden' }); - } - - noteAttributes.project_id = project.id; - } - - const note = await Note.create(noteAttributes); - - // Handle tags - can be an 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.authUserId); - - // Reload note with associations - const noteWithAssociations = await Note.findByPk(note.id, { - include: [ - { - model: Tag, - attributes: ['name', 'uid'], - through: { attributes: [] }, - }, - { - model: Project, - required: false, - attributes: ['name', 'uid'], - }, - ], - }); - - const serializedNote = serializeNote(noteWithAssociations); - res.status(201).json({ - ...serializedNote, - uid: noteWithAssociations.uid, - }); - } catch (error) { - logError('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], - }); - } -}); - -router.patch( - '/note/:uid', - hasAccess( - 'rw', - 'note', - async (req) => { - const uid = extractUidFromSlug(req.params.uid); - // Check if note exists - return null if it doesn't (triggers 404) - const note = await Note.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return note ? note.uid : null; - }, - { notFoundMessage: 'Note not found.' } - ), - async (req, res) => { - try { - const note = await Note.findOne({ - where: { uid: req.params.uid }, - }); - - const { title, content, project_uid, project_id, tags, color } = - req.body; - - const updateData = {}; - if (title !== undefined) updateData.title = title; - if (content !== undefined) updateData.content = content; - if (color !== undefined) updateData.color = color; - - // Handle project assignment - support both project_uid (new) and project_id (legacy) - const projectIdentifier = - project_uid !== undefined ? project_uid : project_id; - - if (projectIdentifier !== undefined) { - if (projectIdentifier && projectIdentifier.toString().trim()) { - let project; - - // Try to find by UID first (new way), then by ID (legacy) - if ( - project_uid !== undefined && - typeof project_uid === 'string' - ) { - const projectUidValue = project_uid.trim(); - project = await Project.findOne({ - where: { uid: projectUidValue }, - }); - } else if (project_id !== undefined) { - // Legacy: find by numeric ID - project = await Project.findByPk(project_id); - } - - if (!project) { - return res - .status(400) - .json({ error: 'Invalid project.' }); - } - const projectAccess = await permissionsService.getAccess( - req.authUserId, - 'project', - project.uid - ); - const isOwner = project.user_id === req.authUserId; - const canWrite = - isOwner || - projectAccess === 'rw' || - projectAccess === 'admin'; - if (!canWrite) { - return res.status(403).json({ error: 'Forbidden' }); - } - updateData.project_id = project.id; - } else { - updateData.project_id = null; - } - } - - 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.authUserId); - } - - // Reload note with associations - const noteWithAssociations = await Note.findByPk(note.id, { - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - { - model: Project, - required: false, - attributes: ['id', 'name', 'uid'], - }, - ], - }); - - res.json(serializeNote(noteWithAssociations)); - } catch (error) { - logError('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], - }); - } - } -); - -router.delete( - '/note/:uid', - hasAccess( - 'rw', - 'note', - async (req) => { - const uid = extractUidFromSlug(req.params.uid); - // Check if note exists - return null if it doesn't (triggers 404) - const note = await Note.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return note ? note.uid : null; - }, - { notFoundMessage: 'Note not found.' } - ), - async (req, res) => { - try { - const note = await Note.findOne({ - where: { uid: req.params.uid }, - }); - - await note.destroy(); - res.json({ message: 'Note deleted successfully.' }); - } catch (error) { - logError('Error deleting note:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -module.exports = router; diff --git a/backend/routes/notifications.js b/backend/routes/notifications.js deleted file mode 100644 index 958620f..0000000 --- a/backend/routes/notifications.js +++ /dev/null @@ -1,159 +0,0 @@ -const express = require('express'); -const { Notification } = require('../models'); -const { logError } = require('../services/logService'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); - -// Middleware to require authentication -router.use((req, res, next) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - req.authUserId = userId; - next(); -}); - -// GET /notifications - Get user's notifications -router.get('/', async (req, res) => { - try { - const { - limit = 10, - offset = 0, - includeRead = 'true', - type, - } = req.query; - - const { notifications, total } = - await Notification.getUserNotifications(req.authUserId, { - limit: parseInt(limit), - offset: parseInt(offset), - includeRead: includeRead === 'true', - type: type || null, - }); - - res.json({ - notifications, - total, - }); - } catch (error) { - logError('Error fetching notifications:', error); - res.status(500).json({ error: 'Failed to fetch notifications' }); - } -}); - -// GET /notifications/unread-count - Get count of unread notifications -router.get('/unread-count', async (req, res) => { - try { - const count = await Notification.getUnreadCount(req.authUserId); - res.json({ count }); - } catch (error) { - logError('Error fetching unread count:', error); - res.status(500).json({ error: 'Failed to fetch unread count' }); - } -}); - -// POST /notifications/:id/read - Mark notification as read -router.post('/:id/read', async (req, res) => { - try { - const notification = await Notification.findOne({ - where: { - id: req.params.id, - user_id: req.authUserId, - }, - }); - - if (!notification) { - return res.status(404).json({ error: 'Notification not found' }); - } - - await notification.markAsRead(); - - res.json({ - notification, - message: 'Notification marked as read', - }); - } catch (error) { - logError('Error marking notification as read:', error); - res.status(500).json({ error: 'Failed to mark notification as read' }); - } -}); - -// POST /notifications/:id/unread - Mark notification as unread -router.post('/:id/unread', async (req, res) => { - try { - const notification = await Notification.findOne({ - where: { - id: req.params.id, - user_id: req.authUserId, - }, - }); - - if (!notification) { - return res.status(404).json({ error: 'Notification not found' }); - } - - await notification.markAsUnread(); - - res.json({ - notification, - message: 'Notification marked as unread', - }); - } catch (error) { - logError('Error marking notification as unread:', error); - res.status(500).json({ - error: 'Failed to mark notification as unread', - }); - } -}); - -// POST /notifications/mark-all-read - Mark all notifications as read -router.post('/mark-all-read', async (req, res) => { - try { - const [count] = await Notification.markAllAsRead(req.authUserId); - - res.json({ - count, - message: `Marked ${count} notifications as read`, - }); - } catch (error) { - logError('Error marking all notifications as read:', error); - res.status(500).json({ - error: 'Failed to mark all notifications as read', - }); - } -}); - -// DELETE /notifications/:id - Soft delete (dismiss) a notification -router.delete('/:id', async (req, res) => { - try { - console.log( - `Attempting to dismiss notification ${req.params.id} for user ${req.authUserId}` - ); - - const notification = await Notification.findOne({ - where: { - id: req.params.id, - user_id: req.authUserId, - dismissed_at: null, // Only allow dismissing non-dismissed notifications - }, - }); - - if (!notification) { - console.log( - `Notification ${req.params.id} not found or already dismissed for user ${req.authUserId}` - ); - return res.status(404).json({ error: 'Notification not found' }); - } - - await notification.dismiss(); - console.log(`Successfully dismissed notification ${req.params.id}`); - - res.json({ message: 'Notification dismissed successfully' }); - } catch (error) { - logError('Error dismissing notification:', error); - res.status(500).json({ error: 'Failed to dismiss notification' }); - } -}); - -module.exports = router; diff --git a/backend/routes/projects.js b/backend/routes/projects.js deleted file mode 100644 index e26a4bd..0000000 --- a/backend/routes/projects.js +++ /dev/null @@ -1,751 +0,0 @@ -const express = require('express'); -const multer = require('multer'); -const path = require('path'); -const { getConfig } = require('../config/config'); -const config = getConfig(); -const fs = require('fs'); -const { - Project, - Task, - Tag, - Area, - Note, - User, - Permission, - sequelize, -} = require('../models'); -const permissionsService = require('../services/permissionsService'); -const { Op } = require('sequelize'); -const { extractUidFromSlug } = require('../utils/slug-utils'); -const { validateTagName } = require('../services/tagsService'); -const { uid } = require('../utils/uid'); -const { logError } = require('../services/logService'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); -const { sortTags } = require('./tasks/core/serializers'); - -// Helper function to serialize a project with sorted tags (including nested tasks and notes) -function serializeProjectWithSortedTags(project) { - const projectJson = project.toJSON ? project.toJSON() : project; - return { - ...projectJson, - Tags: sortTags(projectJson.Tags), - Tasks: projectJson.Tasks - ? projectJson.Tasks.map((task) => ({ - ...task, - Tags: sortTags(task.Tags), - Subtasks: task.Subtasks - ? task.Subtasks.map((subtask) => ({ - ...subtask, - Tags: sortTags(subtask.Tags), - })) - : [], - })) - : [], - Notes: projectJson.Notes - ? projectJson.Notes.map((note) => ({ - ...note, - Tags: sortTags(note.Tags), - })) - : [], - }; -} - -router.use((req, res, next) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - req.authUserId = userId; - next(); -}); -const { hasAccess } = require('../middleware/authorize'); -const { requireAuth } = require('../middleware/auth'); - -// 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; - } -}; - -// Configure multer for file uploads -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - const uploadDir = path.join(config.uploadPath, '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: 10 * 1024 * 1024, // 10MB 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; - - // Validate and filter tag names - const validTagNames = []; - const invalidTags = []; - - for (const tag of tagsData) { - const validation = validateTagName(tag.name); - if (validation.valid) { - // Check for duplicates - if (!validTagNames.includes(validation.name)) { - validTagNames.push(validation.name); - } - } else { - invalidTags.push({ name: tag.name, error: validation.error }); - } - } - - // If there are invalid tags, throw an error - if (invalidTags.length > 0) { - throw new Error( - `Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}` - ); - } - - if (validTagNames.length === 0) { - await project.setTags([]); - return; - } - - // Find existing tags - const existingTags = await Tag.findAll({ - where: { user_id: userId, name: validTagNames }, - }); - - // Create new tags - const existingTagNames = existingTags.map((tag) => tag.name); - const newTagNames = validTagNames.filter( - (name) => !existingTagNames.includes(name) - ); - - 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', - requireAuth, - upload.single('image'), - (req, res) => { - try { - 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) { - logError('Error uploading image:', error); - res.status(500).json({ error: 'Failed to upload image' }); - } - } -); - -router.get('/projects', async (req, res) => { - try { - const { status, state, active, pin_to_sidebar, area_id, area } = - req.query; - // Support both 'status' (new) and 'state' (legacy) query params - const statusFilter = status || state; - - // Base: owned or shared projects - const ownedOrShared = - await permissionsService.ownershipOrPermissionWhere( - 'project', - req.authUserId - ); - let whereClause = ownedOrShared; - - // Filter by status - if (statusFilter && statusFilter !== 'all') { - if (Array.isArray(statusFilter)) { - whereClause.status = { [Op.in]: statusFilter }; - } else { - whereClause.status = statusFilter; - } - } - - // Legacy support for active filter - map to statuses - if (active === 'true') { - whereClause.status = { - [Op.in]: ['planned', 'in_progress', 'waiting'], - }; - } else if (active === 'false') { - whereClause.status = { [Op.in]: ['not_started', 'done'] }; - } - - // 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 - support both numeric area_id and uid-slug area - if (area && area !== '') { - // Extract uid from uid-slug format - const uid = extractUidFromSlug(area); - if (uid) { - const areaRecord = await Area.findOne({ - where: { uid: uid }, - attributes: ['id'], - }); - if (areaRecord) { - // add to AND filter - whereClause = { - [Op.and]: [whereClause, { area_id: areaRecord.id }], - }; - } - } - } else if (area_id && area_id !== '') { - // Legacy support for numeric area_id - whereClause = { [Op.and]: [whereClause, { area_id }] }; - } - - const projects = await Project.findAll({ - where: whereClause, - include: [ - { - model: Task, - required: false, - attributes: ['id', 'status'], - where: { - parent_task_id: null, - recurring_parent_id: null, - }, - }, - { - model: Area, - required: false, - attributes: ['id', 'uid', 'name'], - }, - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - { - model: User, - required: false, - attributes: ['uid'], - }, - ], - order: [['name', 'ASC']], - }); - - const { grouped } = req.query; - - // Calculate task status counts and share counts for each project - const projectIds = projects.map((p) => p.id); - const projectUids = projects.map((p) => p.uid).filter(Boolean); - - // Get share counts for all projects in one query using permissions table - const shareCountMap = {}; - if (projectUids.length > 0) { - const shareCounts = await Permission.findAll({ - attributes: [ - 'resource_uid', - [sequelize.fn('COUNT', sequelize.col('id')), 'count'], - ], - where: { - resource_type: 'project', - resource_uid: { [Op.in]: projectUids }, - }, - group: ['resource_uid'], - raw: true, - }); - - // Create a map of project_uid to share_count - const uidToCountMap = {}; - shareCounts.forEach((item) => { - uidToCountMap[item.resource_uid] = parseInt(item.count, 10); - }); - - // Map uids back to project ids - projects.forEach((project) => { - if (project.uid && uidToCountMap[project.uid]) { - shareCountMap[project.id] = uidToCountMap[project.uid]; - } - }); - } - - 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(); - const shareCount = shareCountMap[project.id] || 0; - - return { - ...projectJson, - tags: sortTags(projectJson.Tags), // Normalize Tags to tags with sorting - due_date_at: formatDate(project.due_date_at), - task_status: taskStatus, - completion_percentage: - taskStatus.total > 0 - ? Math.round((taskStatus.done / taskStatus.total) * 100) - : 0, - user_uid: projectJson.User?.uid, - share_count: shareCount, - is_shared: shareCount > 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) { - logError('Error fetching projects:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// GET /api/project/:uidSlug (UID-slug format only) -router.get( - '/project/:uidSlug', - hasAccess( - 'ro', - 'project', - async (req) => { - const uid = extractUidFromSlug(req.params.uidSlug); - // Check if project exists - return null if it doesn't (triggers 404) - const project = await Project.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return project ? project.uid : null; - }, - { notFoundMessage: 'Project not found' } - ), - async (req, res) => { - try { - const uidPart = extractUidFromSlug(req.params.uidSlug); - const project = await Project.findOne({ - where: { uid: uidPart }, - include: [ - { - model: Task, - required: false, - where: { - parent_task_id: null, - recurring_parent_id: null, - }, - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - required: false, - }, - { - model: Task, - as: 'Subtasks', - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - required: false, - }, - ], - required: false, - }, - ], - }, - { - model: Note, - required: false, - attributes: [ - 'id', - 'uid', - 'title', - 'content', - 'created_at', - 'updated_at', - ], - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - ], - }, - { - model: Area, - required: false, - attributes: ['id', 'uid', 'name'], - }, - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - ], - }); - - const projectJson = project.toJSON(); - - // Normalize task data to match frontend expectations - const normalizedTasks = projectJson.Tasks - ? projectJson.Tasks.map((task) => { - const normalizedTask = { - ...task, - tags: sortTags(task.Tags), - subtasks: (task.Subtasks || []).map((subtask) => ({ - ...subtask, - tags: sortTags(subtask.Tags), - })), - due_date: task.due_date - ? typeof task.due_date === 'string' - ? task.due_date.split('T')[0] - : task.due_date.toISOString().split('T')[0] - : null, - }; - delete normalizedTask.Tags; - delete normalizedTask.Subtasks; - return normalizedTask; - }) - : []; - - // Normalize note data to match frontend expectations - const normalizedNotes = projectJson.Notes - ? projectJson.Notes.map((note) => { - const normalizedNote = { - ...note, - tags: sortTags(note.Tags), - }; - delete normalizedNote.Tags; - return normalizedNote; - }) - : []; - - // Get share count for this project - let shareCount = 0; - if (project.uid) { - const shareCountResult = await Permission.count({ - where: { - resource_type: 'project', - resource_uid: project.uid, - }, - }); - shareCount = shareCountResult || 0; - } - - const result = { - ...projectJson, - tags: sortTags(projectJson.Tags), - Tasks: normalizedTasks, - Notes: normalizedNotes, - due_date_at: formatDate(project.due_date_at), - user_id: project.user_id, - share_count: shareCount, - is_shared: shareCount > 0, - }; - - res.json(result); - } catch (error) { - logError('Error fetching project:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -router.post('/project', async (req, res) => { - try { - const { - name, - description, - area_id, - priority, - due_date_at, - image_url, - status, - state, // Legacy support - 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' }); - } - - // Generate UID explicitly to avoid Sequelize caching issues - const projectUid = uid(); - - const projectData = { - uid: projectUid, - name: name.trim(), - description: description || '', - area_id: area_id || null, - pin_to_sidebar: false, - priority: priority || null, - due_date_at: due_date_at || null, - image_url: image_url || null, - status: status || state || 'not_started', - user_id: req.authUserId, - }; - - // Create is always allowed for the authenticated user; project is owned by creator - const project = await Project.create(projectData); - - // Update tags if provided, but don't let tag errors break project creation - try { - await updateProjectTags(project, tagsData, req.authUserId); - } catch (tagError) { - logError( - 'Tag update failed, but project created successfully:', - tagError.message - ); - } - - res.status(201).json({ - ...project.toJSON(), - uid: projectUid, // Use the UID we explicitly generated - tags: [], // Start with empty tags - they can be added later - due_date_at: formatDate(project.due_date_at), - }); - } catch (error) { - logError('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], - }); - } -}); - -router.patch( - '/project/:uid', - hasAccess( - 'rw', - 'project', - async (req) => { - const uid = extractUidFromSlug(req.params.uid); - // Check if project exists - return null if it doesn't (triggers 404) - const project = await Project.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return project ? project.uid : null; - }, - { notFoundMessage: 'Project not found.' } - ), - async (req, res) => { - try { - const project = await Project.findOne({ - where: { uid: req.params.uid }, - }); - - const { - name, - description, - area_id, - pin_to_sidebar, - priority, - due_date_at, - image_url, - status, - state, // Legacy support - 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 (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; - // Support both status (new) and state (legacy) - if (status !== undefined) updateData.status = status; - else if (state !== undefined) updateData.status = state; - - await project.update(updateData); - await updateProjectTags(project, tagsData, req.authUserId); - - // Reload project with associations - const projectWithAssociations = await Project.findByPk(project.id, { - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - { - model: Area, - required: false, - attributes: ['id', 'uid', 'name'], - }, - ], - }); - - const projectJson = projectWithAssociations.toJSON(); - - res.json({ - ...projectJson, - tags: sortTags(projectJson.Tags), - due_date_at: formatDate(projectWithAssociations.due_date_at), - }); - } catch (error) { - logError('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], - }); - } - } -); - -router.delete( - '/project/:uid', - requireAuth, - hasAccess( - 'rw', - 'project', - async (req) => { - const uid = extractUidFromSlug(req.params.uid); - // Check if project exists - return null if it doesn't (triggers 404) - const project = await Project.findOne({ - where: { uid }, - attributes: ['uid'], - }); - return project ? project.uid : null; - }, - { notFoundMessage: 'Project not found.' } - ), - async (req, res) => { - try { - const project = await Project.findOne({ - where: { uid: req.params.uid }, - }); - - // Use a transaction to ensure atomicity - await sequelize.transaction(async (transaction) => { - // Disable foreign key constraints for this operation - await sequelize.query('PRAGMA foreign_keys = OFF', { - transaction, - }); - - try { - // First, orphan all tasks associated with this project by setting project_id to NULL - await Task.update( - { project_id: null }, - { - where: { - project_id: project.id, - user_id: req.authUserId, - }, - transaction, - } - ); - - // Also orphan all notes associated with this project by setting project_id to NULL - await Note.update( - { project_id: null }, - { - where: { - project_id: project.id, - user_id: req.authUserId, - }, - transaction, - } - ); - - // Then delete the project - await project.destroy({ transaction }); - } finally { - // Re-enable foreign key constraints - await sequelize.query('PRAGMA foreign_keys = ON', { - transaction, - }); - } - }); - - res.json({ message: 'Project successfully deleted' }); - } catch (error) { - logError('Error deleting project:', error); - res.status(400).json({ - error: 'There was a problem deleting the project.', - }); - } - } -); - -module.exports = router; diff --git a/backend/routes/quotes.js b/backend/routes/quotes.js deleted file mode 100644 index 98b5299..0000000 --- a/backend/routes/quotes.js +++ /dev/null @@ -1,31 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const { logError } = require('../services/logService'); -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) { - logError('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) { - logError('Error getting quotes:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -module.exports = router; diff --git a/backend/routes/search.js b/backend/routes/search.js deleted file mode 100644 index 80e4297..0000000 --- a/backend/routes/search.js +++ /dev/null @@ -1,614 +0,0 @@ -const express = require('express'); -const { Task, Tag, Project, Area, Note, sequelize } = require('../models'); -const { Op } = require('sequelize'); -const moment = require('moment-timezone'); -const { serializeTasks } = require('./tasks/core/serializers'); -const router = express.Router(); - -// Helper function to convert priority string to integer -const priorityToInt = (priorityStr) => { - const priorityMap = { - low: 0, - medium: 1, - high: 2, - }; - return priorityMap[priorityStr] !== undefined - ? priorityMap[priorityStr] - : null; -}; - -/** - * Universal search endpoint - * GET /api/search - * Query params: - * - q: search query string - * - filters: comma-separated list of entity types (Task,Project,Area,Note,Tag) - * - priority: filter by priority (low,medium,high) - * - due: filter by due date (today,tomorrow,next_week,next_month) - * - tags: comma-separated list of tag names to filter by - * - recurring: filter by recurrence type (recurring,non_recurring,instances) - * - extras: comma-separated list of extra filters (recurring,overdue,has_content,deferred,has_tags,assigned_to_project) - * - limit: number of results to return (default: 20) - * - offset: number of results to skip (default: 0) - * - excludeSubtasks: if 'true', exclude tasks that have a parent_task_id or recurring_parent_id - */ -router.get('/', async (req, res) => { - try { - const userId = req.currentUser?.id; - if (!userId) { - return res.status(401).json({ error: 'Unauthorized' }); - } - - const { - q: query, - filters, - priority, - due, - defer, - tags: tagsParam, - recurring, - extras: extrasParam, - limit: limitParam, - offset: offsetParam, - excludeSubtasks, - } = req.query; - const searchQuery = query ? query.trim() : ''; - const filterTypes = filters - ? filters.split(',').map((f) => f.trim()) - : ['Task', 'Project', 'Area', 'Note', 'Tag']; - const tagNames = tagsParam - ? tagsParam.split(',').map((t) => t.trim()) - : []; - const extras = - extrasParam && typeof extrasParam === 'string' - ? extrasParam - .split(',') - .map((extra) => extra.trim()) - .filter(Boolean) - : []; - const extrasSet = new Set(extras); - const userTimezone = req.currentUser?.timezone || 'UTC'; - const nowMoment = moment().tz(userTimezone); - const startOfToday = nowMoment.clone().startOf('day'); - const nowDate = nowMoment.toDate(); - - // Pagination support - const hasPagination = - limitParam !== undefined || offsetParam !== undefined; - const limit = hasPagination ? parseInt(limitParam, 10) || 20 : 20; - const offset = hasPagination ? parseInt(offsetParam, 10) || 0 : 0; - - const results = []; - let totalCount = 0; - - // If tags are specified, find their IDs first - let tagIds = []; - if (tagNames.length > 0) { - const tags = await Tag.findAll({ - where: { - user_id: userId, - name: { [Op.in]: tagNames }, - }, - attributes: ['id'], - }); - tagIds = tags.map((tag) => tag.id); - - // If no matching tags found, return empty results - if (tagIds.length === 0) { - return res.json({ results: [] }); - } - } - - // Calculate due date range based on filter - let dueDateCondition = null; - if (due) { - let startDate, endDate; - - switch (due) { - case 'today': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().endOf('day'); - break; - case 'tomorrow': - startDate = startOfToday.clone().add(1, 'day'); - endDate = startOfToday.clone().add(1, 'day').endOf('day'); - break; - case 'next_week': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().add(7, 'days').endOf('day'); - break; - case 'next_month': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().add(1, 'month').endOf('day'); - break; - } - - if (startDate && endDate) { - dueDateCondition = { - due_date: { - [Op.between]: [startDate.toDate(), endDate.toDate()], - }, - }; - } - } - - // Calculate defer until date range based on filter - let deferDateCondition = null; - if (defer) { - let startDate, endDate; - - switch (defer) { - case 'today': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().endOf('day'); - break; - case 'tomorrow': - startDate = startOfToday.clone().add(1, 'day'); - endDate = startOfToday.clone().add(1, 'day').endOf('day'); - break; - case 'next_week': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().add(7, 'days').endOf('day'); - break; - case 'next_month': - startDate = startOfToday.clone(); - endDate = startOfToday.clone().add(1, 'month').endOf('day'); - break; - } - - if (startDate && endDate) { - deferDateCondition = { - defer_until: { - [Op.between]: [startDate.toDate(), endDate.toDate()], - }, - }; - } - } - - // Search Tasks - if (filterTypes.includes('Task')) { - const taskConditions = { - user_id: userId, - }; - const taskExtraConditions = []; - - // Exclude subtasks and recurring instances if requested - if (excludeSubtasks === 'true') { - taskConditions.parent_task_id = null; - taskConditions.recurring_parent_id = null; - } - - // Add search query filter if specified - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - taskConditions[Op.or] = [ - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Task.name')), - { [Op.like]: `%${lowerQuery}%` } - ), - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Task.note')), - { [Op.like]: `%${lowerQuery}%` } - ), - ]; - } - - // Add priority filter if specified (convert string to integer) - if (priority) { - const priorityInt = priorityToInt(priority); - if (priorityInt !== null) { - taskConditions.priority = priorityInt; - } - } - - // Add due date filter if specified - if (dueDateCondition) { - taskExtraConditions.push(dueDateCondition); - } - - // Add defer until filter if specified - if (deferDateCondition) { - taskExtraConditions.push(deferDateCondition); - } - - // Add recurring filter if specified - if (recurring) { - switch (recurring) { - case 'recurring': - // Show only recurring templates (not instances) - taskConditions.recurrence_type = { [Op.ne]: 'none' }; - taskConditions.recurring_parent_id = null; - break; - case 'non_recurring': - // Show only non-recurring tasks (not templates or instances) - taskConditions[Op.or] = [ - { recurrence_type: 'none' }, - { recurrence_type: null }, - ]; - taskConditions.recurring_parent_id = null; - break; - case 'instances': - // Show only recurring instances (spawned from templates) - taskConditions.recurring_parent_id = { [Op.ne]: null }; - break; - } - } - - if (extrasSet.has('recurring')) { - taskExtraConditions.push({ - [Op.or]: [ - { recurrence_type: { [Op.ne]: 'none' } }, - { recurring_parent_id: { [Op.ne]: null } }, - ], - }); - } - - if (extrasSet.has('overdue')) { - taskExtraConditions.push({ - due_date: { [Op.lt]: nowDate }, - }); - taskExtraConditions.push({ - completed_at: null, - }); - } - - if (extrasSet.has('has_content')) { - const noteHasContent = sequelize.where( - sequelize.fn( - 'LENGTH', - sequelize.fn('TRIM', sequelize.col('Task.note')) - ), - { [Op.gt]: 0 } - ); - taskExtraConditions.push(noteHasContent); - } - - if (extrasSet.has('deferred')) { - taskExtraConditions.push({ - defer_until: { [Op.gt]: nowDate }, - }); - } - - if (extrasSet.has('assigned_to_project')) { - taskExtraConditions.push({ - project_id: { [Op.ne]: null }, - }); - } - - if (taskExtraConditions.length > 0) { - if (taskConditions[Op.and]) { - taskConditions[Op.and].push(...taskExtraConditions); - } else { - taskConditions[Op.and] = taskExtraConditions; - } - } - - const taskInclude = [ - { - model: Project, - attributes: ['id', 'uid', 'name'], - }, - { - model: Task, - as: 'Subtasks', - include: [ - { - model: Tag, - attributes: ['id', 'name', 'uid'], - through: { attributes: [] }, - }, - ], - }, - ]; - - const requireTags = tagIds.length > 0 || extrasSet.has('has_tags'); - const tagInclude = { - model: Tag, - through: { attributes: [] }, - attributes: ['id', 'name', 'uid'], - required: requireTags, - }; - - if (tagIds.length > 0) { - tagInclude.where = { - id: { [Op.in]: tagIds }, - }; - } - - taskInclude.push(tagInclude); - - // Count total tasks if pagination is requested - if (hasPagination) { - const countInclude = requireTags ? [tagInclude] : undefined; - totalCount += await Task.count({ - where: taskConditions, - include: countInclude, - distinct: true, - }); - } - - const tasks = await Task.findAll({ - where: taskConditions, - include: taskInclude, - limit: limit, - offset: offset, - order: [['updated_at', 'DESC']], - }); - - // Use proper serialization to include all task data - const serializedTasks = await serializeTasks( - tasks, - req.currentUser?.timezone || 'UTC' - ); - - results.push( - ...serializedTasks.map((task) => ({ - type: 'Task', - ...task, - description: task.note, - })) - ); - } - - // Search Projects - if (filterTypes.includes('Project')) { - const projectConditions = { - user_id: userId, - }; - - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - projectConditions[Op.or] = [ - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Project.name')), - { [Op.like]: `%${lowerQuery}%` } - ), - sequelize.where( - sequelize.fn( - 'LOWER', - sequelize.col('Project.description') - ), - { [Op.like]: `%${lowerQuery}%` } - ), - ]; - } - - if (priority) { - projectConditions.priority = priority; - } - - // Add due date filter if specified (projects use due_date_at field) - if (dueDateCondition) { - const projectDueCondition = { - due_date_at: dueDateCondition.due_date, - }; - Object.assign(projectConditions, projectDueCondition); - } - - const requireProjectTags = - tagIds.length > 0 || extrasSet.has('has_tags'); - const projectInclude = []; - - if (requireProjectTags) { - const projectTagInclude = { - model: Tag, - through: { attributes: [] }, - attributes: [], - required: true, - }; - - if (tagIds.length > 0) { - projectTagInclude.where = { - id: { [Op.in]: tagIds }, - }; - } - - projectInclude.push(projectTagInclude); - } - - // Count total projects if pagination is requested - if (hasPagination) { - totalCount += await Project.count({ - where: projectConditions, - include: - projectInclude.length > 0 ? projectInclude : undefined, - distinct: true, - }); - } - - const projects = await Project.findAll({ - where: projectConditions, - include: projectInclude.length > 0 ? projectInclude : undefined, - limit: limit, - offset: offset, - order: [['updated_at', 'DESC']], - }); - - results.push( - ...projects.map((project) => ({ - type: 'Project', - id: project.id, - uid: project.uid, - name: project.name, - description: project.description, - priority: project.priority, - status: project.status, - })) - ); - } - - // Search Areas - if (filterTypes.includes('Area')) { - const areaConditions = { - user_id: userId, - }; - - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - areaConditions[Op.or] = [ - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Area.name')), - { [Op.like]: `%${lowerQuery}%` } - ), - sequelize.where( - sequelize.fn( - 'LOWER', - sequelize.col('Area.description') - ), - { [Op.like]: `%${lowerQuery}%` } - ), - ]; - } - - // Count total areas if pagination is requested - if (hasPagination) { - totalCount += await Area.count({ - where: areaConditions, - }); - } - - const areas = await Area.findAll({ - where: areaConditions, - limit: limit, - offset: offset, - order: [['updated_at', 'DESC']], - }); - - results.push( - ...areas.map((area) => ({ - type: 'Area', - id: area.id, - uid: area.uid, - name: area.name, - description: area.description, - })) - ); - } - - // Search Notes - if (filterTypes.includes('Note')) { - const noteConditions = { - user_id: userId, - }; - - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - noteConditions[Op.or] = [ - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Note.title')), - { [Op.like]: `%${lowerQuery}%` } - ), - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Note.content')), - { [Op.like]: `%${lowerQuery}%` } - ), - ]; - } - - const noteInclude = []; - - // Add tag filter if specified - if (tagIds.length > 0) { - noteInclude.push({ - model: Tag, - where: { - id: { [Op.in]: tagIds }, - }, - through: { attributes: [] }, - attributes: [], - required: true, - }); - } - - // Count total notes if pagination is requested - if (hasPagination) { - totalCount += await Note.count({ - where: noteConditions, - include: noteInclude.length > 0 ? noteInclude : undefined, - distinct: true, - }); - } - - const notes = await Note.findAll({ - where: noteConditions, - include: noteInclude.length > 0 ? noteInclude : undefined, - limit: limit, - offset: offset, - order: [['updated_at', 'DESC']], - }); - - results.push( - ...notes.map((note) => ({ - type: 'Note', - id: note.id, - uid: note.uid, - name: note.title, - title: note.title, - description: note.content - ? note.content.substring(0, 100) - : '', - })) - ); - } - - // Search Tags - if (filterTypes.includes('Tag')) { - const tagConditions = { - user_id: userId, - }; - - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - tagConditions[Op.and] = [ - sequelize.where( - sequelize.fn('LOWER', sequelize.col('Tag.name')), - { [Op.like]: `%${lowerQuery}%` } - ), - ]; - } - - // Count total tags if pagination is requested - if (hasPagination) { - totalCount += await Tag.count({ - where: tagConditions, - }); - } - - const tags = await Tag.findAll({ - where: tagConditions, - limit: limit, - offset: offset, - order: [['name', 'ASC']], - }); - - results.push( - ...tags.map((tag) => ({ - type: 'Tag', - id: tag.id, - uid: tag.uid, - name: tag.name, - })) - ); - } - - // Return results with pagination metadata if requested - if (hasPagination) { - res.json({ - results, - pagination: { - total: totalCount, - limit: limit, - offset: offset, - hasMore: offset + results.length < totalCount, - }, - }); - } else { - res.json({ results }); - } - } catch (error) { - console.error('Search error:', error); - res.status(500).json({ error: 'Search failed' }); - } -}); - -module.exports = router; diff --git a/backend/routes/shares.js b/backend/routes/shares.js deleted file mode 100644 index a6fb9b8..0000000 --- a/backend/routes/shares.js +++ /dev/null @@ -1,282 +0,0 @@ -const express = require('express'); -const { User, Permission, Project, Task, Note } = require('../models'); -const { execAction } = require('../services/execAction'); -const { logError } = require('../services/logService'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); - -const getUserIdOrUnauthorized = (req, res) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - res.status(401).json({ error: 'Authentication required' }); - return null; - } - return userId; -}; - -const permissionsService = require('../services/permissionsService'); -const { isAdmin } = require('../services/rolesService'); - -// Helper function to check if user is the actual owner of a resource -async function isResourceOwner(userId, resourceType, resourceUid) { - let resource = null; - - if (resourceType === 'project') { - resource = await Project.findOne({ - where: { uid: resourceUid }, - attributes: ['user_id'], - raw: true, - }); - } else if (resourceType === 'task') { - resource = await Task.findOne({ - where: { uid: resourceUid }, - attributes: ['user_id'], - raw: true, - }); - } else if (resourceType === 'note') { - resource = await Note.findOne({ - where: { uid: resourceUid }, - attributes: ['user_id'], - raw: true, - }); - } - - return resource && resource.user_id === userId; -} - -// POST /api/shares -router.post('/shares', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const { resource_type, resource_uid, target_user_email, access_level } = - req.body; - if ( - !resource_type || - !resource_uid || - !target_user_email || - !access_level - ) { - return res.status(400).json({ error: 'Missing parameters' }); - } - // Only owner (or admin) can grant shares - const userIsAdmin = await isAdmin(userId); - const userIsOwner = await isResourceOwner( - userId, - resource_type, - resource_uid - ); - if (!userIsAdmin && !userIsOwner) { - return res.status(403).json({ error: 'Forbidden' }); - } - const target = await User.findOne({ - where: { email: target_user_email }, - }); - if (!target) - return res.status(404).json({ error: 'Target user not found' }); - - // Prevent sharing with the owner (owner already has full access) - const resource = await (async () => { - if (resource_type === 'project') { - return await Project.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'task') { - return await Task.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'note') { - return await Note.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } - return null; - })(); - - if (!resource) { - return res.status(404).json({ error: 'Resource not found' }); - } - - if (resource.user_id === target.id) { - return res.status(400).json({ - error: 'Cannot grant permissions to the owner. Owner already has full access.', - }); - } - - await execAction({ - verb: 'share_grant', - actorUserId: userId, - targetUserId: target.id, - resourceType: resource_type, - resourceUid: resource_uid, - accessLevel: access_level, - }); - res.status(204).end(); - } catch (err) { - logError('Error sharing resource:', err); - res.status(400).json({ error: 'Unable to share resource' }); - } -}); - -// DELETE /api/shares -router.delete('/shares', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const { resource_type, resource_uid, target_user_id } = req.body; - if (!resource_type || !resource_uid || !target_user_id) { - return res.status(400).json({ error: 'Missing parameters' }); - } - // Only owner (or admin) can revoke shares - const userIsAdmin = await isAdmin(userId); - const userIsOwner = await isResourceOwner( - userId, - resource_type, - resource_uid - ); - if (!userIsAdmin && !userIsOwner) { - return res.status(403).json({ error: 'Forbidden' }); - } - - // Prevent revoking permissions from the owner - const resource = await (async () => { - if (resource_type === 'project') { - return await Project.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'task') { - return await Task.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'note') { - return await Note.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } - return null; - })(); - - if (resource && resource.user_id === Number(target_user_id)) { - return res.status(400).json({ - error: 'Cannot revoke permissions from the owner.', - }); - } - - await execAction({ - verb: 'share_revoke', - actorUserId: userId, - targetUserId: Number(target_user_id), - resourceType: resource_type, - resourceUid: resource_uid, - }); - res.status(204).end(); - } catch (err) { - logError('Error revoking share:', err); - res.status(400).json({ error: 'Unable to revoke share' }); - } -}); - -// GET /api/shares?resource_type=...&resource_uid=... -router.get('/shares', async (req, res) => { - try { - const userId = getUserIdOrUnauthorized(req, res); - if (!userId) return; - const { resource_type, resource_uid } = req.query; - if (!resource_type || !resource_uid) { - return res.status(400).json({ error: 'Missing parameters' }); - } - - // Only owner (or admin) can view shares - const userIsAdmin = await isAdmin(userId); - const userIsOwner = await isResourceOwner( - userId, - resource_type, - resource_uid - ); - if (!userIsAdmin && !userIsOwner) { - return res.status(403).json({ error: 'Forbidden' }); - } - - // Get resource owner information - let ownerInfo = null; - const resource = await (async () => { - if (resource_type === 'project') { - return await Project.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'task') { - return await Task.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } else if (resource_type === 'note') { - return await Note.findOne({ - where: { uid: resource_uid }, - attributes: ['user_id'], - }); - } - return null; - })(); - - if (resource) { - const owner = await User.findByPk(resource.user_id, { - attributes: ['id', 'email', 'avatar_image'], - }); - if (owner) { - ownerInfo = { - user_id: owner.id, - access_level: 'owner', - created_at: null, - email: owner.email, - avatar_image: owner.avatar_image, - is_owner: true, - }; - } - } - - const rows = await Permission.findAll({ - where: { resource_type, resource_uid, propagation: 'direct' }, - attributes: ['user_id', 'access_level', 'created_at'], - raw: true, - }); - // Attach emails and avatar images for display - const userIds = Array.from(new Set(rows.map((r) => r.user_id))).filter( - Boolean - ); - let usersById = {}; - if (userIds.length) { - const users = await User.findAll({ - where: { id: userIds }, - attributes: ['id', 'email', 'avatar_image'], - raw: true, - }); - usersById = users.reduce((acc, u) => { - acc[u.id] = { email: u.email, avatar_image: u.avatar_image }; - return acc; - }, {}); - } - const withEmails = rows.map((r) => ({ - ...r, - email: usersById[r.user_id]?.email || null, - avatar_image: usersById[r.user_id]?.avatar_image || null, - is_owner: false, - })); - - // Prepend owner to the list - const allShares = ownerInfo ? [ownerInfo, ...withEmails] : withEmails; - - res.json({ shares: allShares }); - } catch (err) { - logError('Error listing shares:', err); - res.status(400).json({ error: 'Unable to list shares' }); - } -}); - -module.exports = router; diff --git a/backend/routes/tags.js b/backend/routes/tags.js deleted file mode 100644 index 71253b5..0000000 --- a/backend/routes/tags.js +++ /dev/null @@ -1,218 +0,0 @@ -const express = require('express'); -const { Tag, Task, Note, Project, sequelize } = require('../models'); -const { extractUidFromSlug } = require('../utils/slug-utils'); -const { validateTagName } = require('../services/tagsService'); -const router = express.Router(); -const _ = require('lodash'); -const { Op } = require('sequelize'); -const { logError } = require('../services/logService'); - -router.get('/tags', async (req, res) => { - try { - const tags = await Tag.findAll({ - where: { user_id: req.currentUser.id }, - attributes: ['name', 'uid'], - order: [[sequelize.fn('LOWER', sequelize.col('name')), 'ASC']], - }); - res.json(tags); - } catch (error) { - logError('Error fetching tags:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// GET /api/tag/:identifier (supports name and uid) -router.get('/tag', async (req, res) => { - try { - const { uid, name } = req.query; - - let whereClause = { - user_id: req.currentUser.id, - }; - if (!_.isEmpty(uid)) { - whereClause.uid = uid; - } - if (!_.isEmpty(name)) { - whereClause.name = decodeURIComponent(name); - } - - const tag = await Tag.findOne({ - where: whereClause, - attributes: ['name', 'uid'], - }); - - if (_.isEmpty(tag)) { - return res.status(404).json({ error: 'Tag not found' }); - } - - res.json(tag); - } catch (error) { - logError('Error fetching tag:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post('/tag', async (req, res) => { - try { - const { name } = req.body; - - const validation = validateTagName(name); - if (!validation.valid) { - return res.status(400).json({ error: validation.error }); - } - - // Check if tag already exists for this user - const existingTag = await Tag.findOne({ - where: { - name: validation.name, - user_id: req.currentUser.id, - }, - }); - - if (existingTag) { - return res.status(409).json({ - error: `A tag with the name "${validation.name}" already exists.`, - }); - } - - const tag = await Tag.create({ - name: validation.name, - user_id: req.currentUser.id, - }); - - res.status(201).json({ - uid: tag.uid, - name: tag.name, - }); - } catch (error) { - logError('Error creating tag:', error); - // Check if it's a unique constraint violation - if (error.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ - error: 'A tag with this name already exists.', - }); - } - res.status(400).json({ - error: 'There was a problem creating the tag.', - }); - } -}); - -// PATCH /api/tag/:identifier (supports both ID and name) -router.patch('/tag/:identifier', async (req, res) => { - try { - const param = decodeURIComponent(req.params.identifier); - let whereClause = { - [Op.or]: [ - { name: param, user_id: req.currentUser.id }, - { uid: param, user_id: req.currentUser.id }, - ], - }; - - const tag = await Tag.findOne({ - where: whereClause, - }); - - if (!tag) { - return res.status(404).json({ error: 'Tag not found' }); - } - - const { name } = req.body; - const validation = validateTagName(name); - if (!validation.valid) { - return res.status(400).json({ error: validation.error }); - } - - // Check if another tag with the same name already exists - if (validation.name !== tag.name) { - const existingTag = await Tag.findOne({ - where: { - name: validation.name, - user_id: req.currentUser.id, - id: { [Op.ne]: tag.id }, - }, - }); - - if (existingTag) { - return res.status(409).json({ - error: `A tag with the name "${validation.name}" already exists.`, - }); - } - } - - await tag.update({ name: validation.name }); - - res.json({ - id: tag.id, - name: tag.name, - }); - } catch (error) { - logError('Error updating tag:', error); - // Check if it's a unique constraint violation - if (error.name === 'SequelizeUniqueConstraintError') { - return res.status(409).json({ - error: 'A tag with this name already exists.', - }); - } - res.status(400).json({ - error: 'There was a problem updating the tag.', - }); - } -}); - -// DELETE /api/tag/:identifier (supports uid and name) -router.delete('/tag/:identifier', async (req, res) => { - const transaction = await sequelize.transaction(); - - try { - const param = decodeURIComponent(req.params.identifier); - let whereClause = { - [Op.or]: [ - { name: param, user_id: req.currentUser.id }, - { uid: param, user_id: req.currentUser.id }, - ], - }; - - const tag = await Tag.findOne({ - where: whereClause, - }); - - if (_.isEmpty(tag)) { - await transaction.rollback(); - return res.status(404).json({ error: 'Tag not found' }); - } - - // Remove all associations before deleting the tag by manually deleting from junction tables - // Only delete from tables that exist - await Promise.all([ - sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', { - replacements: [tag.id], - type: sequelize.QueryTypes.DELETE, - transaction, - }), - sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', { - replacements: [tag.id], - type: sequelize.QueryTypes.DELETE, - transaction, - }), - sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', { - replacements: [tag.id], - type: sequelize.QueryTypes.DELETE, - transaction, - }), - ]); - - await tag.destroy({ transaction }); - await transaction.commit(); - - res.json({ message: 'Tag successfully deleted' }); - } catch (error) { - await transaction.rollback(); - logError('Error deleting tag:', error); - res.status(400).json({ - error: 'There was a problem deleting the tag.', - }); - } -}); - -module.exports = router; diff --git a/backend/routes/users.js b/backend/routes/users.js deleted file mode 100644 index 307440d..0000000 --- a/backend/routes/users.js +++ /dev/null @@ -1,809 +0,0 @@ -const express = require('express'); -const { User, Role, ApiToken } = require('../models'); -const _ = require('lodash'); -const { logError } = require('../services/logService'); -const taskSummaryService = require('../services/taskSummaryService'); -const router = express.Router(); -const { getAuthenticatedUserId } = require('../utils/request-utils'); -const { - createApiToken, - revokeApiToken, - deleteApiToken, - serializeApiToken, -} = require('../services/apiTokenService'); -const { apiKeyManagementLimiter } = require('../middleware/rateLimiter'); -const multer = require('multer'); -const path = require('path'); -const fs = require('fs').promises; - -router.use((req, res, next) => { - const userId = getAuthenticatedUserId(req); - if (!userId) { - return res.status(401).json({ error: 'Authentication required' }); - } - req.authUserId = userId; - next(); -}); - -const VALID_FREQUENCIES = [ - 'daily', - 'weekdays', - 'weekly', - '1h', - '2h', - '4h', - '8h', - '12h', -]; - -// Configure multer for avatar uploads -const storage = multer.diskStorage({ - destination: async (req, file, cb) => { - const uploadDir = path.join(__dirname, '../uploads/avatars'); - try { - await fs.mkdir(uploadDir, { recursive: true }); - cb(null, uploadDir); - } catch (error) { - cb(error, uploadDir); - } - }, - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(file.originalname); - cb(null, `avatar-${req.authUserId}-${uniqueSuffix}${ext}`); - }, -}); - -const fileFilter = (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 (JPEG, PNG, GIF, WebP) are allowed!')); - } -}; - -const upload = multer({ - storage: storage, - limits: { - fileSize: 5 * 1024 * 1024, // 5MB limit - }, - fileFilter: fileFilter, -}); - -router.get('/users', async (req, res) => { - try { - const users = await User.findAll({ - attributes: ['id', 'email', 'name', 'surname'], - order: [['email', 'ASC']], - }); - - // Fetch roles in bulk - const roles = await Role.findAll({ - attributes: ['user_id', 'is_admin'], - }); - const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin])); - - const result = users.map((u) => ({ - id: u.id, - email: u.email, - name: u.name, - surname: u.surname, - role: userIdToRole.get(u.id) ? 'admin' : 'user', - })); - - res.json(result); - } catch (err) { - logError('Error listing users:', err); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/profile', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId, { - attributes: [ - 'uid', - 'email', - 'name', - 'surname', - 'appearance', - 'language', - 'timezone', - 'first_day_of_week', - 'avatar_image', - 'telegram_bot_token', - 'telegram_chat_id', - 'telegram_allowed_users', - 'task_summary_enabled', - 'task_summary_frequency', - 'task_intelligence_enabled', - 'auto_suggest_next_actions_enabled', - 'pomodoro_enabled', - 'today_settings', - 'sidebar_settings', - 'productivity_assistant_enabled', - 'next_task_suggestion_enabled', - 'notification_preferences', - 'keyboard_shortcuts', - ], - }); - - 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) { - logError('Error parsing today_settings:', error); - user.today_settings = null; - } - } - if (user.ui_settings && typeof user.ui_settings === 'string') { - try { - user.ui_settings = JSON.parse(user.ui_settings); - } catch (error) { - logError('Error parsing ui_settings:', error); - user.ui_settings = null; - } - } - - res.json(user); - } catch (error) { - logError('Error fetching profile:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.patch('/profile', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - if (!user) { - return res.status(404).json({ error: 'Profile not found.' }); - } - - const { - name, - surname, - appearance, - language, - timezone, - first_day_of_week, - avatar_image, - telegram_bot_token, - telegram_allowed_users, - task_intelligence_enabled, - task_summary_enabled, - task_summary_frequency, - auto_suggest_next_actions_enabled, - productivity_assistant_enabled, - next_task_suggestion_enabled, - pomodoro_enabled, - ui_settings, - notification_preferences, - keyboard_shortcuts, - currentPassword, - newPassword, - } = req.body; - - const allowedUpdates = {}; - if (name !== undefined) allowedUpdates.name = name; - if (surname !== undefined) allowedUpdates.surname = surname; - if (appearance !== undefined) allowedUpdates.appearance = appearance; - if (language !== undefined) allowedUpdates.language = language; - if (timezone !== undefined) allowedUpdates.timezone = timezone; - if (first_day_of_week !== undefined) - allowedUpdates.first_day_of_week = first_day_of_week; - if (avatar_image !== undefined) - allowedUpdates.avatar_image = avatar_image; - if (telegram_bot_token !== undefined) - allowedUpdates.telegram_bot_token = telegram_bot_token; - if (telegram_allowed_users !== undefined) - allowedUpdates.telegram_allowed_users = telegram_allowed_users; - 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 (productivity_assistant_enabled !== undefined) - allowedUpdates.productivity_assistant_enabled = - productivity_assistant_enabled; - if (next_task_suggestion_enabled !== undefined) - allowedUpdates.next_task_suggestion_enabled = - next_task_suggestion_enabled; - if (pomodoro_enabled !== undefined) - allowedUpdates.pomodoro_enabled = pomodoro_enabled; - if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings; - if (notification_preferences !== undefined) - allowedUpdates.notification_preferences = notification_preferences; - if (keyboard_shortcuts !== undefined) - allowedUpdates.keyboard_shortcuts = keyboard_shortcuts; - - // Validate first_day_of_week if provided - if (first_day_of_week !== undefined) { - if ( - typeof first_day_of_week !== 'number' || - first_day_of_week < 0 || - first_day_of_week > 6 - ) { - return res.status(400).json({ - field: 'first_day_of_week', - error: 'First day of week must be a number between 0 (Sunday) and 6 (Saturday)', - }); - } - } - - // 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: [ - 'uid', - 'email', - 'name', - 'surname', - 'appearance', - 'language', - 'timezone', - 'avatar_image', - 'telegram_bot_token', - 'telegram_chat_id', - 'telegram_allowed_users', - 'task_intelligence_enabled', - 'task_summary_enabled', - 'task_summary_frequency', - 'auto_suggest_next_actions_enabled', - 'productivity_assistant_enabled', - 'next_task_suggestion_enabled', - 'pomodoro_enabled', - 'notification_preferences', - 'keyboard_shortcuts', - ], - }); - - res.json(updatedUser); - } catch (error) { - logError('Error updating profile:', error); - res.status(400).json({ - error: 'Failed to update profile.', - details: error.errors - ? error.errors.map((e) => e.message) - : [error.message], - }); - } -}); - -router.post('/profile/avatar', upload.single('avatar'), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - const user = await User.findByPk(req.authUserId); - if (!user) { - // Clean up uploaded file - await fs.unlink(req.file.path).catch(() => {}); - return res.status(404).json({ error: 'User not found' }); - } - - // Delete old avatar file if it exists - if (user.avatar_image) { - const oldAvatarPath = path.join( - __dirname, - '../uploads/avatars', - path.basename(user.avatar_image) - ); - await fs.unlink(oldAvatarPath).catch(() => { - // Ignore errors if file doesn't exist - }); - } - - // Store relative path in database - const avatarUrl = `/uploads/avatars/${path.basename(req.file.path)}`; - await user.update({ avatar_image: avatarUrl }); - - res.json({ - success: true, - avatar_image: avatarUrl, - message: 'Avatar uploaded successfully', - }); - } catch (error) { - // Clean up uploaded file on error - if (req.file) { - await fs.unlink(req.file.path).catch(() => {}); - } - logError('Error uploading avatar:', error); - res.status(500).json({ - error: 'Failed to upload avatar', - details: error.message, - }); - } -}); - -router.delete('/profile/avatar', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - // Delete avatar file if it exists - if (user.avatar_image) { - const avatarPath = path.join( - __dirname, - '../uploads/avatars', - path.basename(user.avatar_image) - ); - await fs.unlink(avatarPath).catch(() => { - // Ignore errors if file doesn't exist - }); - } - - await user.update({ avatar_image: null }); - - res.json({ - success: true, - message: 'Avatar removed successfully', - }); - } catch (error) { - logError('Error removing avatar:', error); - res.status(500).json({ - error: 'Failed to remove avatar', - details: error.message, - }); - } -}); - -router.post('/profile/change-password', async (req, res) => { - try { - 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.authUserId); - 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) { - logError('Error changing password:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.get('/profile/api-keys', apiKeyManagementLimiter, async (req, res) => { - try { - const tokens = await ApiToken.findAll({ - where: { user_id: req.authUserId }, - order: [['created_at', 'DESC']], - }); - - res.json(tokens.map(serializeApiToken)); - } catch (error) { - logError('Error listing API keys:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post('/profile/api-keys', apiKeyManagementLimiter, async (req, res) => { - try { - const { name, expires_at } = req.body || {}; - - if (!name || _.isEmpty(name.trim())) { - return res.status(400).json({ error: 'API key name is required.' }); - } - - let expiresAtDate = null; - if (expires_at) { - const parsedDate = new Date(expires_at); - if (Number.isNaN(parsedDate.getTime())) { - return res - .status(400) - .json({ error: 'expires_at must be a valid date.' }); - } - expiresAtDate = parsedDate; - } - - const { rawToken, tokenRecord } = await createApiToken({ - userId: req.authUserId, - name: name.trim(), - expiresAt: expiresAtDate, - }); - - res.status(201).json({ - token: rawToken, - apiKey: serializeApiToken(tokenRecord), - }); - } catch (error) { - logError('Error creating API key:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.post( - '/profile/api-keys/:id/revoke', - apiKeyManagementLimiter, - async (req, res) => { - try { - const tokenId = parseInt(req.params.id, 10); - if (Number.isNaN(tokenId)) { - return res.status(400).json({ error: 'Invalid API key id.' }); - } - - const token = await revokeApiToken(tokenId, req.authUserId); - if (!token) { - return res.status(404).json({ error: 'API key not found.' }); - } - - res.json(serializeApiToken(token)); - } catch (error) { - logError('Error revoking API key:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -router.delete( - '/profile/api-keys/:id', - apiKeyManagementLimiter, - async (req, res) => { - try { - const tokenId = parseInt(req.params.id, 10); - if (Number.isNaN(tokenId)) { - return res.status(400).json({ error: 'Invalid API key id.' }); - } - - const deleted = await deleteApiToken(tokenId, req.authUserId); - if (!deleted) { - return res.status(404).json({ error: 'API key not found.' }); - } - - res.status(204).send(); - } catch (error) { - logError('Error deleting API key:', error); - res.status(500).json({ error: 'Internal server error' }); - } - } -); - -router.post('/profile/task-summary/toggle', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - 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) { - logError('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], - }); - } -}); - -router.post('/profile/task-summary/frequency', async (req, res) => { - try { - 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.authUserId); - 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) { - logError('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], - }); - } -}); - -router.post('/profile/task-summary/send-now', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - 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.' }); - } - - // 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) { - logError('Error sending task summary:', error); - res.status(400).json({ - error: 'Error sending message to Telegram.', - details: error.message, - }); - } -}); - -router.get('/profile/task-summary/status', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - 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) { - logError('Error fetching task summary status:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.put('/profile/today-settings', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - if (!user) { - return res.status(404).json({ error: 'User not found.' }); - } - - const { - showMetrics, - projectShowMetrics, - showProductivity, - showNextTaskSuggestion, - showSuggestions, - showDueToday, - showCompleted, - showProgressBar, - showDailyQuote, - } = req.body; - - const todaySettings = { - projectShowMetrics: - projectShowMetrics !== undefined - ? projectShowMetrics - : (user.today_settings?.projectShowMetrics ?? true), - showMetrics: - showMetrics !== undefined - ? showMetrics - : user.today_settings?.showMetrics || false, - showProductivity: - showProductivity !== undefined - ? showProductivity - : user.today_settings?.showProductivity || false, - showNextTaskSuggestion: - showNextTaskSuggestion !== undefined - ? showNextTaskSuggestion - : user.today_settings?.showNextTaskSuggestion || false, - showSuggestions: - showSuggestions !== undefined - ? showSuggestions - : user.today_settings?.showSuggestions || 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, - }; - - // Sync productivity features with today settings - const profileUpdates = {}; - if (showProductivity !== undefined) { - profileUpdates.productivity_assistant_enabled = showProductivity; - } - if (showNextTaskSuggestion !== undefined) { - profileUpdates.next_task_suggestion_enabled = - showNextTaskSuggestion; - } - - await user.update({ - today_settings: todaySettings, - ...profileUpdates, - }); - - res.json({ - success: true, - today_settings: todaySettings, - }); - } catch (error) { - logError('Error updating today settings:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -router.put('/profile/sidebar-settings', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - if (!user) { - return res.status(404).json({ error: 'User not found.' }); - } - - const { pinnedViewsOrder } = req.body; - - if (!Array.isArray(pinnedViewsOrder)) { - return res.status(400).json({ - error: 'pinnedViewsOrder must be an array', - }); - } - - const sidebarSettings = { - pinnedViewsOrder, - }; - - await user.update({ - sidebar_settings: sidebarSettings, - }); - - res.json({ - success: true, - sidebar_settings: sidebarSettings, - }); - } catch (error) { - logError('Error updating sidebar settings:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// Update generic UI settings (e.g., project metrics preferences) -router.put('/profile/ui-settings', async (req, res) => { - try { - const user = await User.findByPk(req.authUserId); - if (!user) { - return res.status(404).json({ error: 'User not found.' }); - } - - const { project } = req.body; - - const currentSettings = (user.ui_settings && - typeof user.ui_settings === 'object' - ? user.ui_settings - : {}) || { project: { details: {} } }; - - const newSettings = { - ...currentSettings, - project: { - ...(currentSettings.project || {}), - ...(project || {}), - details: { - ...((currentSettings.project && - currentSettings.project.details) || - {}), - ...((project && project.details) || {}), - }, - }, - }; - - await user.update({ ui_settings: newSettings }); - - res.json({ success: true, ui_settings: newSettings }); - } catch (error) { - logError('Error updating ui settings:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -module.exports = router; diff --git a/backend/routes/views.js b/backend/routes/views.js deleted file mode 100644 index 64e86d1..0000000 --- a/backend/routes/views.js +++ /dev/null @@ -1,185 +0,0 @@ -const express = require('express'); -const { View } = require('../models'); -const { Op } = require('sequelize'); -const { logError } = require('../services/logService'); -const router = express.Router(); - -// GET /api/views - Get all views for the current user -router.get('/', async (req, res) => { - try { - const views = await View.findAll({ - where: { user_id: req.currentUser.id }, - order: [ - ['is_pinned', 'DESC'], - ['created_at', 'DESC'], - ], - }); - res.json(views); - } catch (error) { - logError('Error fetching views:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// GET /api/views/pinned - Get pinned views for the current user -router.get('/pinned', async (req, res) => { - try { - const views = await View.findAll({ - where: { - user_id: req.currentUser.id, - is_pinned: true, - }, - order: [['created_at', 'DESC']], - }); - res.json(views); - } catch (error) { - logError('Error fetching pinned views:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// GET /api/views/:identifier - Get a specific view by uid -router.get('/:identifier', async (req, res) => { - try { - const identifier = decodeURIComponent(req.params.identifier); - - const view = await View.findOne({ - where: { - uid: identifier, - user_id: req.currentUser.id, - }, - }); - - if (!view) { - return res.status(404).json({ error: 'View not found' }); - } - - res.json(view); - } catch (error) { - logError('Error fetching view:', error); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// POST /api/views - Create a new view -router.post('/', async (req, res) => { - try { - const { - name, - search_query, - filters, - priority, - due, - defer, - tags, - extras, - recurring, - } = req.body; - - if (!name || name.trim() === '') { - return res.status(400).json({ error: 'View name is required' }); - } - - const view = await View.create({ - name: name.trim(), - user_id: req.currentUser.id, - search_query: search_query || null, - filters: filters || [], - priority: priority || null, - due: due || null, - defer: defer || null, - tags: tags || [], - extras: extras || [], - recurring: recurring || null, - is_pinned: false, - }); - - res.status(201).json(view); - } catch (error) { - logError('Error creating view:', error); - res.status(400).json({ - error: 'There was a problem creating the view.', - }); - } -}); - -// PATCH /api/views/:identifier - Update a view -router.patch('/:identifier', async (req, res) => { - try { - const identifier = decodeURIComponent(req.params.identifier); - - const view = await View.findOne({ - where: { - uid: identifier, - user_id: req.currentUser.id, - }, - }); - - if (!view) { - return res.status(404).json({ error: 'View not found' }); - } - - const { - name, - search_query, - filters, - priority, - due, - defer, - tags, - extras, - recurring, - is_pinned, - } = req.body; - - const updates = {}; - if (name !== undefined) updates.name = name.trim(); - if (search_query !== undefined) updates.search_query = search_query; - if (filters !== undefined) updates.filters = filters; - if (priority !== undefined) updates.priority = priority; - if (due !== undefined) updates.due = due; - if (defer !== undefined) updates.defer = defer; - if (tags !== undefined) updates.tags = tags; - if (extras !== undefined) updates.extras = extras; - if (recurring !== undefined) updates.recurring = recurring; - if (is_pinned !== undefined) updates.is_pinned = is_pinned; - - await view.update(updates); - - res.json(view); - } catch (error) { - logError('Error updating view:', error); - res.status(400).json({ - error: 'There was a problem updating the view.', - }); - } -}); - -// DELETE /api/views/:identifier - Delete a view -router.delete('/:identifier', async (req, res) => { - try { - const identifier = decodeURIComponent(req.params.identifier); - - const view = await View.findOne({ - where: { - uid: identifier, - user_id: req.currentUser.id, - }, - }); - - if (!view) { - return res.status(404).json({ error: 'View not found' }); - } - - await view.destroy(); - - res.json({ message: 'View successfully deleted' }); - } catch (error) { - logError('Error deleting view:', error); - res.status(400).json({ - error: 'There was a problem deleting the view.', - }); - } -}); - -module.exports = router; diff --git a/backend/scripts/reset-and-seed.js b/backend/scripts/reset-and-seed.js index ceceeeb..4c1abb0 100755 --- a/backend/scripts/reset-and-seed.js +++ b/backend/scripts/reset-and-seed.js @@ -48,11 +48,13 @@ async function main() { // Step 4: Generate notifications console.log('4️⃣ Generating notifications...'); - const { checkDueTasks } = require('../services/dueTaskService'); + const { checkDueTasks } = require('../modules/tasks/dueTaskService'); const { checkDeferredTasks, - } = require('../services/deferredTaskService'); - const { checkDueProjects } = require('../services/dueProjectService'); + } = require('../modules/tasks/deferredTaskService'); + const { + checkDueProjects, + } = require('../modules/projects/dueProjectService'); const dueTasksResult = await checkDueTasks(); const deferredTasksResult = await checkDeferredTasks(); diff --git a/backend/scripts/user-create.js b/backend/scripts/user-create.js index d93d9a3..50db626 100755 --- a/backend/scripts/user-create.js +++ b/backend/scripts/user-create.js @@ -12,7 +12,7 @@ const { createOrUpdateUser, validateEmail, validatePassword, -} = require('../services/userService'); +} = require('../modules/users/userService'); const { Role } = require('../models'); async function createUser() { diff --git a/backend/seeders/dev-seeder.js b/backend/seeders/dev-seeder.js index 1a52a8a..40263c6 100644 --- a/backend/seeders/dev-seeder.js +++ b/backend/seeders/dev-seeder.js @@ -626,7 +626,7 @@ async function seedDatabase() { const { logTaskCreated, logStatusChange, - } = require('../services/taskEventService'); + } = require('../modules/tasks/taskEventService'); // Create events for completed tasks to show user patterns const completedTasks = tasks.filter((t) => t.status === 2); diff --git a/backend/shared/database/BaseRepository.js b/backend/shared/database/BaseRepository.js new file mode 100644 index 0000000..0f9102b --- /dev/null +++ b/backend/shared/database/BaseRepository.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Base repository class providing common data access methods. + * Extend this class to create entity-specific repositories. + */ +class BaseRepository { + constructor(model) { + this.model = model; + } + + async findById(id, options = {}) { + return this.model.findByPk(id, options); + } + + async findOne(where, options = {}) { + return this.model.findOne({ where, ...options }); + } + + async findAll(where = {}, options = {}) { + return this.model.findAll({ where, ...options }); + } + + async create(data, options = {}) { + return this.model.create(data, options); + } + + async update(instance, data, options = {}) { + return instance.update(data, options); + } + + async destroy(instance, options = {}) { + return instance.destroy(options); + } + + async count(where = {}, options = {}) { + return this.model.count({ where, ...options }); + } + + async exists(where) { + const count = await this.count(where); + return count > 0; + } +} + +module.exports = BaseRepository; diff --git a/backend/shared/errors/AppError.js b/backend/shared/errors/AppError.js new file mode 100644 index 0000000..77f15e9 --- /dev/null +++ b/backend/shared/errors/AppError.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Base application error class. + * All custom errors should extend this class. + */ +class AppError extends Error { + constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + error: this.message, + code: this.code, + }; + } +} + +module.exports = AppError; diff --git a/backend/shared/errors/index.js b/backend/shared/errors/index.js new file mode 100644 index 0000000..d18abcb --- /dev/null +++ b/backend/shared/errors/index.js @@ -0,0 +1,42 @@ +'use strict'; + +const AppError = require('./AppError'); + +class NotFoundError extends AppError { + constructor(message = 'Resource not found') { + super(message, 404, 'NOT_FOUND'); + } +} + +class ValidationError extends AppError { + constructor(message = 'Validation failed') { + super(message, 400, 'VALIDATION_ERROR'); + } +} + +class ConflictError extends AppError { + constructor(message = 'Resource already exists') { + super(message, 409, 'CONFLICT'); + } +} + +class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 401, 'UNAUTHORIZED'); + } +} + +class ForbiddenError extends AppError { + constructor(message = 'Forbidden') { + super(message, 403, 'FORBIDDEN'); + } +} + +module.exports = { + AppError, + NotFoundError, + ValidationError, + ConflictError, + UnauthorizedError, + ForbiddenError, +}; diff --git a/backend/shared/middleware/errorHandler.js b/backend/shared/middleware/errorHandler.js new file mode 100644 index 0000000..6f41651 --- /dev/null +++ b/backend/shared/middleware/errorHandler.js @@ -0,0 +1,52 @@ +'use strict'; + +const { AppError } = require('../errors'); +const { logError } = require('../../services/logService'); + +/** + * Global error handler middleware. + * Converts errors to consistent JSON responses. + */ +function errorHandler(err, req, res, next) { + // Log error for debugging + if (process.env.NODE_ENV === 'development') { + console.error(err); + } else { + logError('Error:', err); + } + + // Handle our custom AppError instances + if (err instanceof AppError) { + return res.status(err.statusCode).json(err.toJSON()); + } + + // Handle Sequelize validation errors + if (err.name === 'SequelizeValidationError') { + return res.status(400).json({ + error: err.errors.map((e) => e.message).join(', '), + code: 'VALIDATION_ERROR', + }); + } + + // Handle Sequelize unique constraint errors + if (err.name === 'SequelizeUniqueConstraintError') { + return res.status(409).json({ + error: 'A resource with this identifier already exists.', + code: 'CONFLICT', + }); + } + + // Handle unknown errors + const statusCode = err.statusCode || 500; + const message = + process.env.NODE_ENV === 'production' + ? 'Internal server error' + : err.message; + + res.status(statusCode).json({ + error: message, + code: 'INTERNAL_ERROR', + }); +} + +module.exports = errorHandler; diff --git a/backend/tests/README.md b/backend/tests/README.md deleted file mode 100644 index 9dde6c3..0000000 --- a/backend/tests/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Backend Test Suite - -This directory contains the test suite for the tududi backend Express application. - -## Structure - -``` -tests/ -├── unit/ # Unit tests for individual components -│ ├── models/ # Model tests -│ ├── middleware/ # Middleware tests -│ └── services/ # Service tests -├── integration/ # Integration tests for API endpoints -├── fixtures/ # Test data fixtures -└── helpers/ # Test utilities and helpers -``` - -## 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 -``` - -## 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 - -## 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 - -## Best Practices - -1. **Isolation**: Each test should be independent and not rely on other tests -2. **Cleanup**: Database is automatically cleaned between tests -3. **Authentication**: Use test utilities for creating authenticated requests -4. **Descriptive Names**: Test names should clearly describe what is being tested -5. **Coverage**: Aim for high test coverage of critical business logic - -## Dependencies - -- **Jest**: Test framework -- **Supertest**: HTTP testing library for integration tests -- **cross-env**: Cross-platform environment variable setting diff --git a/backend/tests/integration/monthly-recurrence-current-month.test.js b/backend/tests/integration/monthly-recurrence-current-month.test.js index 1bde249..0020be9 100644 --- a/backend/tests/integration/monthly-recurrence-current-month.test.js +++ b/backend/tests/integration/monthly-recurrence-current-month.test.js @@ -4,7 +4,7 @@ const { Task } = require('../../models'); const { createTestUser } = require('../helpers/testUtils'); const { calculateNextIterations, -} = require('../../routes/tasks/operations/recurring'); +} = require('../../modules/tasks/operations/recurring'); describe('Monthly Recurrence - Current Month Bug Fix', () => { let user, agent; diff --git a/backend/tests/integration/notification-soft-delete.test.js b/backend/tests/integration/notification-soft-delete.test.js index 0c1ef97..516186b 100644 --- a/backend/tests/integration/notification-soft-delete.test.js +++ b/backend/tests/integration/notification-soft-delete.test.js @@ -149,7 +149,9 @@ describe('Notification Soft Delete', () => { }); // Run the due task service - const { checkDueTasks } = require('../../services/dueTaskService'); + const { + checkDueTasks, + } = require('../../modules/tasks/dueTaskService'); let result = await checkDueTasks(); // Should create 1 notification diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 2602401..09020f3 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -2,7 +2,9 @@ const request = require('supertest'); const app = require('../../app'); const { Task, RecurringCompletion, sequelize } = require('../../models'); const { createTestUser } = require('../helpers/testUtils'); -const { calculateNextDueDate } = require('../../services/recurringTaskService'); +const { + calculateNextDueDate, +} = require('../../modules/tasks/recurringTaskService'); describe('Recurring Tasks', () => { let user, agent; diff --git a/backend/tests/integration/tasks-metrics.test.js b/backend/tests/integration/tasks-metrics.test.js index bfd7e13..9b2f0f7 100644 --- a/backend/tests/integration/tasks-metrics.test.js +++ b/backend/tests/integration/tasks-metrics.test.js @@ -2,7 +2,7 @@ const { Task, Project } = require('../../models'); const { createTestUser } = require('../helpers/testUtils'); const { getTaskMetrics, -} = require('../../routes/tasks/queries/metrics-computation'); +} = require('../../modules/tasks/queries/metrics-computation'); const dayFromNow = (days) => new Date(Date.now() + days * 24 * 60 * 60 * 1000); diff --git a/backend/tests/integration/telegram-duplicate-scenario.test.js b/backend/tests/integration/telegram-duplicate-scenario.test.js index f036f73..f69790b 100644 --- a/backend/tests/integration/telegram-duplicate-scenario.test.js +++ b/backend/tests/integration/telegram-duplicate-scenario.test.js @@ -1,5 +1,5 @@ const { User, InboxItem, sequelize } = require('../../models'); -const telegramPoller = require('../../services/telegramPoller'); +const telegramPoller = require('../../modules/telegram/telegramPoller'); // Mock the HTTPS module to simulate Telegram API responses jest.mock('https', () => { diff --git a/backend/tests/integration/telegram-duplicates.test.js b/backend/tests/integration/telegram-duplicates.test.js index 0c99a02..53867e1 100644 --- a/backend/tests/integration/telegram-duplicates.test.js +++ b/backend/tests/integration/telegram-duplicates.test.js @@ -1,7 +1,7 @@ const request = require('supertest'); const app = require('../../app'); const { User, InboxItem, sequelize } = require('../../models'); -const telegramPoller = require('../../services/telegramPoller'); +const telegramPoller = require('../../modules/telegram/telegramPoller'); describe('Telegram Duplicate Prevention Integration Tests', () => { let testUser; diff --git a/backend/tests/unit/services/functional-services.test.js b/backend/tests/unit/services/functional-services.test.js index fa8e5e3..8cd5dfe 100644 --- a/backend/tests/unit/services/functional-services.test.js +++ b/backend/tests/unit/services/functional-services.test.js @@ -1,7 +1,7 @@ -const taskScheduler = require('../../../services/taskScheduler'); -const telegramPoller = require('../../../services/telegramPoller'); -const quotesService = require('../../../services/quotesService'); -const taskSummaryService = require('../../../services/taskSummaryService'); +const taskScheduler = require('../../../modules/tasks/taskScheduler'); +const telegramPoller = require('../../../modules/telegram/telegramPoller'); +const quotesService = require('../../../modules/quotes/quotesService'); +const taskSummaryService = require('../../../modules/tasks/taskSummaryService'); describe('Functional Services', () => { describe('TaskScheduler', () => { diff --git a/backend/tests/unit/services/telegramAuth.test.js b/backend/tests/unit/services/telegramAuth.test.js index e3579bb..d9d2c2d 100644 --- a/backend/tests/unit/services/telegramAuth.test.js +++ b/backend/tests/unit/services/telegramAuth.test.js @@ -1,6 +1,6 @@ const { _isAuthorizedTelegramUser, -} = require('../../../services/telegramPoller'); +} = require('../../../modules/telegram/telegramPoller'); describe('Telegram Authorization', () => { describe('isAuthorizedTelegramUser', () => { diff --git a/backend/tests/unit/services/telegramPoller.test.js b/backend/tests/unit/services/telegramPoller.test.js index 488d4f6..9cffe75 100644 --- a/backend/tests/unit/services/telegramPoller.test.js +++ b/backend/tests/unit/services/telegramPoller.test.js @@ -1,5 +1,5 @@ const { User, InboxItem } = require('../../../models'); -const telegramPoller = require('../../../services/telegramPoller'); +const telegramPoller = require('../../../modules/telegram/telegramPoller'); const https = require('https'); // Mock the database models diff --git a/backend/tests/unit/services/userService.test.js b/backend/tests/unit/services/userService.test.js index 5ef39e5..71a9ed6 100644 --- a/backend/tests/unit/services/userService.test.js +++ b/backend/tests/unit/services/userService.test.js @@ -1,7 +1,7 @@ const { validateEmail, validatePassword, -} = require('../../../services/userService'); +} = require('../../../modules/users/userService'); describe('userService validation functions', () => { describe('validateEmail', () => { diff --git a/backend/tests/unit/utils/validation.test.js b/backend/tests/unit/utils/validation.test.js index f7bde24..792c4a3 100644 --- a/backend/tests/unit/utils/validation.test.js +++ b/backend/tests/unit/utils/validation.test.js @@ -1,4 +1,4 @@ -const { validateTagName } = require('../../../services/tagsService'); +const { validateTagName } = require('../../../modules/tags/tagsService'); describe('validation utils', () => { describe('validateTagName', () => {