Fix bug 366 (#764)

* Optimize DB

* Clean up names

* fixup! Clean up names

* fixup! fixup! Clean up names
This commit is contained in:
Chris 2026-01-07 18:18:07 +02:00 committed by GitHub
parent b4de9c23eb
commit 542be2c1e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
175 changed files with 8503 additions and 5587 deletions

View file

@ -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() {

View file

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

View file

@ -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`
);
}
}
},
};

View file

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

View file

@ -212,7 +212,7 @@ module.exports = (sequelize) => {
NotificationModel
) {
try {
const telegramService = require('../services/telegramNotificationService');
const telegramService = require('../modules/telegram/telegramNotificationService');
if (!message) {
return;

View file

@ -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'],
},
],
}
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
'use strict';
const routes = require('./routes');
const authService = require('./service');
module.exports = { routes, authService };

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
'use strict';
const routes = require('./routes');
const backupService = require('./service');
module.exports = { routes, backupService };

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
'use strict';
const routes = require('./routes');
const featureFlagsService = require('./service');
module.exports = {
routes,
featureFlagsService,
};

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
'use strict';
const routes = require('./routes');
const habitsService = require('./service');
const habitsRepository = require('./repository');
module.exports = { routes, habitsService, habitsRepository };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,11 @@
'use strict';
const routes = require('./routes');
const notificationsService = require('./service');
const notificationsRepository = require('./repository');
module.exports = {
routes,
notificationsService,
notificationsRepository,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
'use strict';
const routes = require('./routes');
module.exports = { routes };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
'use strict';
const routes = require('./routes');
const sharesService = require('./service');
const sharesRepository = require('./repository');
module.exports = { routes, sharesService, sharesRepository };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ const {
getTaskCompletionTime,
getUserProductivityMetrics,
getTaskActivitySummary,
} = require('../../services/taskEventService');
} = require('./taskEventService');
const { logError } = require('../../services/logService');
const router = express.Router();

View file

@ -0,0 +1,5 @@
'use strict';
const routes = require('./routes');
module.exports = { routes };

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more