Fix bug 366 (#764)
* Optimize DB * Clean up names * fixup! Clean up names * fixup! fixup! Clean up names
This commit is contained in:
parent
b4de9c23eb
commit
542be2c1e9
175 changed files with 8503 additions and 5587 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ module.exports = (sequelize) => {
|
|||
NotificationModel
|
||||
) {
|
||||
try {
|
||||
const telegramService = require('../services/telegramNotificationService');
|
||||
const telegramService = require('../modules/telegram/telegramNotificationService');
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
|
|
|||
111
backend/modules/admin/controller.js
Normal file
111
backend/modules/admin/controller.js
Normal 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;
|
||||
38
backend/modules/admin/index.js
Normal file
38
backend/modules/admin/index.js
Normal 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,
|
||||
};
|
||||
188
backend/modules/admin/repository.js
Normal file
188
backend/modules/admin/repository.js
Normal 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();
|
||||
17
backend/modules/admin/routes.js
Normal file
17
backend/modules/admin/routes.js
Normal 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;
|
||||
258
backend/modules/admin/service.js
Normal file
258
backend/modules/admin/service.js
Normal 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();
|
||||
78
backend/modules/admin/validation.js
Normal file
78
backend/modules/admin/validation.js
Normal 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,
|
||||
};
|
||||
104
backend/modules/areas/controller.js
Normal file
104
backend/modules/areas/controller.js
Normal 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;
|
||||
26
backend/modules/areas/index.js
Normal file
26
backend/modules/areas/index.js
Normal 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,
|
||||
};
|
||||
63
backend/modules/areas/repository.js
Normal file
63
backend/modules/areas/repository.js
Normal 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;
|
||||
15
backend/modules/areas/routes.js
Normal file
15
backend/modules/areas/routes.js
Normal 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;
|
||||
92
backend/modules/areas/service.js
Normal file
92
backend/modules/areas/service.js
Normal 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();
|
||||
40
backend/modules/areas/validation.js
Normal file
40
backend/modules/areas/validation.js
Normal 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,
|
||||
};
|
||||
103
backend/modules/auth/controller.js
Normal file
103
backend/modules/auth/controller.js
Normal 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;
|
||||
6
backend/modules/auth/index.js
Normal file
6
backend/modules/auth/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const authService = require('./service');
|
||||
|
||||
module.exports = { routes, authService };
|
||||
|
|
@ -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(
|
||||
16
backend/modules/auth/routes.js
Normal file
16
backend/modules/auth/routes.js
Normal 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;
|
||||
216
backend/modules/auth/service.js
Normal file
216
backend/modules/auth/service.js
Normal 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();
|
||||
207
backend/modules/backup/controller.js
Normal file
207
backend/modules/backup/controller.js
Normal 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;
|
||||
6
backend/modules/backup/index.js
Normal file
6
backend/modules/backup/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const backupService = require('./service');
|
||||
|
||||
module.exports = { routes, backupService };
|
||||
59
backend/modules/backup/routes.js
Normal file
59
backend/modules/backup/routes.js
Normal 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;
|
||||
220
backend/modules/backup/service.js
Normal file
220
backend/modules/backup/service.js
Normal 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();
|
||||
16
backend/modules/feature-flags/controller.js
Normal file
16
backend/modules/feature-flags/controller.js
Normal 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;
|
||||
9
backend/modules/feature-flags/index.js
Normal file
9
backend/modules/feature-flags/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const featureFlagsService = require('./service');
|
||||
|
||||
module.exports = {
|
||||
routes,
|
||||
featureFlagsService,
|
||||
};
|
||||
9
backend/modules/feature-flags/routes.js
Normal file
9
backend/modules/feature-flags/routes.js
Normal 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;
|
||||
16
backend/modules/feature-flags/service.js
Normal file
16
backend/modules/feature-flags/service.js
Normal 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();
|
||||
109
backend/modules/habits/controller.js
Normal file
109
backend/modules/habits/controller.js
Normal 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;
|
||||
|
|
@ -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
|
||||
7
backend/modules/habits/index.js
Normal file
7
backend/modules/habits/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const habitsService = require('./service');
|
||||
const habitsRepository = require('./repository');
|
||||
|
||||
module.exports = { routes, habitsService, habitsRepository };
|
||||
56
backend/modules/habits/repository.js
Normal file
56
backend/modules/habits/repository.js
Normal 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();
|
||||
29
backend/modules/habits/routes.js
Normal file
29
backend/modules/habits/routes.js
Normal 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;
|
||||
96
backend/modules/habits/service.js
Normal file
96
backend/modules/habits/service.js
Normal 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();
|
||||
131
backend/modules/inbox/controller.js
Normal file
131
backend/modules/inbox/controller.js
Normal 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;
|
||||
32
backend/modules/inbox/index.js
Normal file
32
backend/modules/inbox/index.js
Normal 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,
|
||||
};
|
||||
117
backend/modules/inbox/repository.js
Normal file
117
backend/modules/inbox/repository.js
Normal 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;
|
||||
17
backend/modules/inbox/routes.js
Normal file
17
backend/modules/inbox/routes.js
Normal 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;
|
||||
154
backend/modules/inbox/service.js
Normal file
154
backend/modules/inbox/service.js
Normal 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();
|
||||
70
backend/modules/inbox/validation.js
Normal file
70
backend/modules/inbox/validation.js
Normal 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,
|
||||
};
|
||||
128
backend/modules/notes/controller.js
Normal file
128
backend/modules/notes/controller.js
Normal 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;
|
||||
28
backend/modules/notes/index.js
Normal file
28
backend/modules/notes/index.js
Normal 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,
|
||||
};
|
||||
119
backend/modules/notes/repository.js
Normal file
119
backend/modules/notes/repository.js
Normal 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;
|
||||
43
backend/modules/notes/routes.js
Normal file
43
backend/modules/notes/routes.js
Normal 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;
|
||||
309
backend/modules/notes/service.js
Normal file
309
backend/modules/notes/service.js
Normal 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;
|
||||
47
backend/modules/notes/validation.js
Normal file
47
backend/modules/notes/validation.js
Normal 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,
|
||||
};
|
||||
86
backend/modules/notifications/controller.js
Normal file
86
backend/modules/notifications/controller.js
Normal 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;
|
||||
11
backend/modules/notifications/index.js
Normal file
11
backend/modules/notifications/index.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const notificationsService = require('./service');
|
||||
const notificationsRepository = require('./repository');
|
||||
|
||||
module.exports = {
|
||||
routes,
|
||||
notificationsService,
|
||||
notificationsRepository,
|
||||
};
|
||||
30
backend/modules/notifications/repository.js
Normal file
30
backend/modules/notifications/repository.js
Normal 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();
|
||||
20
backend/modules/notifications/routes.js
Normal file
20
backend/modules/notifications/routes.js
Normal 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;
|
||||
65
backend/modules/notifications/service.js
Normal file
65
backend/modules/notifications/service.js
Normal 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();
|
||||
126
backend/modules/projects/controller.js
Normal file
126
backend/modules/projects/controller.js
Normal 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;
|
||||
|
|
@ -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
|
||||
30
backend/modules/projects/index.js
Normal file
30
backend/modules/projects/index.js
Normal 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,
|
||||
};
|
||||
256
backend/modules/projects/repository.js
Normal file
256
backend/modules/projects/repository.js
Normal 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();
|
||||
102
backend/modules/projects/routes.js
Normal file
102
backend/modules/projects/routes.js
Normal 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;
|
||||
364
backend/modules/projects/service.js
Normal file
364
backend/modules/projects/service.js
Normal 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;
|
||||
50
backend/modules/projects/validation.js
Normal file
50
backend/modules/projects/validation.js
Normal 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,
|
||||
};
|
||||
28
backend/modules/quotes/controller.js
Normal file
28
backend/modules/quotes/controller.js
Normal 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;
|
||||
5
backend/modules/quotes/index.js
Normal file
5
backend/modules/quotes/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
|
||||
module.exports = { routes };
|
||||
10
backend/modules/quotes/routes.js
Normal file
10
backend/modules/quotes/routes.js
Normal 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;
|
||||
29
backend/modules/search/controller.js
Normal file
29
backend/modules/search/controller.js
Normal 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;
|
||||
29
backend/modules/search/index.js
Normal file
29
backend/modules/search/index.js
Normal 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,
|
||||
};
|
||||
134
backend/modules/search/repository.js
Normal file
134
backend/modules/search/repository.js
Normal 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();
|
||||
9
backend/modules/search/routes.js
Normal file
9
backend/modules/search/routes.js
Normal 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;
|
||||
581
backend/modules/search/service.js
Normal file
581
backend/modules/search/service.js
Normal 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();
|
||||
74
backend/modules/search/validation.js
Normal file
74
backend/modules/search/validation.js
Normal 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,
|
||||
};
|
||||
86
backend/modules/shares/controller.js
Normal file
86
backend/modules/shares/controller.js
Normal 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;
|
||||
7
backend/modules/shares/index.js
Normal file
7
backend/modules/shares/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
const sharesService = require('./service');
|
||||
const sharesRepository = require('./repository');
|
||||
|
||||
module.exports = { routes, sharesService, sharesRepository };
|
||||
61
backend/modules/shares/repository.js
Normal file
61
backend/modules/shares/repository.js
Normal 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();
|
||||
11
backend/modules/shares/routes.js
Normal file
11
backend/modules/shares/routes.js
Normal 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;
|
||||
188
backend/modules/shares/service.js
Normal file
188
backend/modules/shares/service.js
Normal 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();
|
||||
91
backend/modules/tags/controller.js
Normal file
91
backend/modules/tags/controller.js
Normal 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;
|
||||
26
backend/modules/tags/index.js
Normal file
26
backend/modules/tags/index.js
Normal 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,
|
||||
};
|
||||
113
backend/modules/tags/repository.js
Normal file
113
backend/modules/tags/repository.js
Normal 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();
|
||||
15
backend/modules/tags/routes.js
Normal file
15
backend/modules/tags/routes.js
Normal 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;
|
||||
124
backend/modules/tags/service.js
Normal file
124
backend/modules/tags/service.js
Normal 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();
|
||||
42
backend/modules/tags/validation.js
Normal file
42
backend/modules/tags/validation.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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
|
||||
|
|
@ -6,7 +6,7 @@ const {
|
|||
getTaskCompletionTime,
|
||||
getUserProductivityMetrics,
|
||||
getTaskActivitySummary,
|
||||
} = require('../../services/taskEventService');
|
||||
} = require('./taskEventService');
|
||||
const { logError } = require('../../services/logService');
|
||||
const router = express.Router();
|
||||
|
||||
5
backend/modules/tasks/index.js
Normal file
5
backend/modules/tasks/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
const routes = require('./routes');
|
||||
|
||||
module.exports = { routes };
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue