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 session = require('express-session');
|
||||||
const SequelizeStore = require('connect-session-sequelize')(session.Store);
|
const SequelizeStore = require('connect-session-sequelize')(session.Store);
|
||||||
const { sequelize } = require('./models');
|
const { sequelize } = require('./models');
|
||||||
const { initializeTelegramPolling } = require('./services/telegramInitializer');
|
const {
|
||||||
const taskScheduler = require('./services/taskScheduler');
|
initializeTelegramPolling,
|
||||||
|
} = require('./modules/telegram/telegramInitializer');
|
||||||
|
const taskScheduler = require('./modules/tasks/taskScheduler');
|
||||||
const { setConfig, getConfig } = require('./config/config');
|
const { setConfig, getConfig } = require('./config/config');
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const API_VERSION = process.env.API_VERSION || 'v1';
|
const API_VERSION = process.env.API_VERSION || 'v1';
|
||||||
|
|
@ -107,6 +109,30 @@ const {
|
||||||
authenticatedApiLimiter,
|
authenticatedApiLimiter,
|
||||||
} = require('./middleware/rateLimiter');
|
} = 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
|
// Swagger documentation - enabled by default, protected by authentication
|
||||||
// Mounted on /api-docs to avoid conflicts with API routes
|
// Mounted on /api-docs to avoid conflicts with API routes
|
||||||
if (config.swagger.enabled) {
|
if (config.swagger.enabled) {
|
||||||
|
|
@ -164,29 +190,28 @@ if (API_VERSION && API_BASE_PATH !== '/api') {
|
||||||
}
|
}
|
||||||
healthPaths.forEach(registerHealthCheck);
|
healthPaths.forEach(registerHealthCheck);
|
||||||
|
|
||||||
// Routes
|
|
||||||
const registerApiRoutes = (basePath) => {
|
const registerApiRoutes = (basePath) => {
|
||||||
app.use(basePath, require('./routes/auth'));
|
app.use(basePath, authModule.routes);
|
||||||
app.use(basePath, require('./routes/feature-flags'));
|
app.use(basePath, featureFlagsModule.routes);
|
||||||
|
|
||||||
app.use(basePath, requireAuth);
|
app.use(basePath, requireAuth);
|
||||||
app.use(basePath, require('./routes/tasks'));
|
app.use(basePath, tasksModule.routes);
|
||||||
app.use(`${basePath}/habits`, require('./routes/habits'));
|
app.use(basePath, habitsModule.routes);
|
||||||
app.use(basePath, require('./routes/projects'));
|
app.use(basePath, projectsModule.routes);
|
||||||
app.use(basePath, require('./routes/admin'));
|
app.use(basePath, adminModule.routes);
|
||||||
app.use(basePath, require('./routes/shares'));
|
app.use(basePath, sharesModule.routes);
|
||||||
app.use(basePath, require('./routes/areas'));
|
app.use(basePath, areasModule.routes);
|
||||||
app.use(basePath, require('./routes/notes'));
|
app.use(basePath, notesModule.routes);
|
||||||
app.use(basePath, require('./routes/tags'));
|
app.use(basePath, tagsModule.routes);
|
||||||
app.use(basePath, require('./routes/users'));
|
app.use(basePath, usersModule.routes);
|
||||||
app.use(basePath, require('./routes/inbox'));
|
app.use(basePath, inboxModule.routes);
|
||||||
app.use(basePath, require('./routes/url'));
|
app.use(basePath, urlModule.routes);
|
||||||
app.use(basePath, require('./routes/telegram'));
|
app.use(basePath, telegramModule.routes);
|
||||||
app.use(basePath, require('./routes/quotes'));
|
app.use(basePath, quotesModule.routes);
|
||||||
app.use(`${basePath}/backup`, require('./routes/backup'));
|
app.use(basePath, backupModule.routes);
|
||||||
app.use(`${basePath}/search`, require('./routes/search'));
|
app.use(basePath, searchModule.routes);
|
||||||
app.use(`${basePath}/views`, require('./routes/views'));
|
app.use(basePath, viewsModule.routes);
|
||||||
app.use(`${basePath}/notifications`, require('./routes/notifications'));
|
app.use(basePath, notificationsModule.routes);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility
|
// 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.
|
// Error handling middleware (handles AppError and Sequelize errors)
|
||||||
// We shouldn't be here normally!
|
app.use(errorHandler);
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize database and start server
|
// Initialize database and start server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { User } = require('../models');
|
const { User } = require('../models');
|
||||||
const { findValidTokenByValue } = require('../services/apiTokenService');
|
const { findValidTokenByValue } = require('../modules/users/apiTokenService');
|
||||||
|
|
||||||
const getBearerToken = (req) => {
|
const getBearerToken = (req) => {
|
||||||
const authHeader = req.headers?.authorization || '';
|
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);
|
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 User = require('./user')(sequelize);
|
||||||
const Area = require('./area')(sequelize);
|
const Area = require('./area')(sequelize);
|
||||||
const Project = require('./project')(sequelize);
|
const Project = require('./project')(sequelize);
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ module.exports = (sequelize) => {
|
||||||
NotificationModel
|
NotificationModel
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const telegramService = require('../services/telegramNotificationService');
|
const telegramService = require('../modules/telegram/telegramNotificationService');
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,39 @@ module.exports = (sequelize) => {
|
||||||
name: 'tasks_parent_task_id_order',
|
name: 'tasks_parent_task_id_order',
|
||||||
fields: ['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 crypto = require('crypto');
|
||||||
const { User, Setting } = require('../models');
|
const { User, Setting } = require('../../models');
|
||||||
const { getConfig } = require('../config/config');
|
const { getConfig } = require('../../config/config');
|
||||||
const { logError, logInfo } = require('./logService');
|
const { logError, logInfo } = require('../../services/logService');
|
||||||
const { sendEmail } = require('./emailService');
|
const { sendEmail } = require('../../services/emailService');
|
||||||
const { validateEmail, validatePassword } = require('./userService');
|
const { validateEmail, validatePassword } = require('../users/userService');
|
||||||
|
|
||||||
const isRegistrationEnabled = async () => {
|
const isRegistrationEnabled = async () => {
|
||||||
const setting = await Setting.findOne({
|
const setting = await Setting.findOne({
|
||||||
|
|
@ -117,7 +117,7 @@ const verifyUserEmail = async (token) => {
|
||||||
|
|
||||||
const sendVerificationEmail = async (user, verificationToken) => {
|
const sendVerificationEmail = async (user, verificationToken) => {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const { isEmailEnabled } = require('./emailService');
|
const { isEmailEnabled } = require('../../services/emailService');
|
||||||
|
|
||||||
if (!isEmailEnabled()) {
|
if (!isEmailEnabled()) {
|
||||||
logInfo(
|
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');
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
class HabitService {
|
class HabitService {
|
||||||
|
|
@ -257,7 +257,9 @@ class HabitService {
|
||||||
} else {
|
} else {
|
||||||
// Strict: check if today matches recurrence pattern
|
// Strict: check if today matches recurrence pattern
|
||||||
// Leverage existing recurringTaskService logic
|
// Leverage existing recurringTaskService logic
|
||||||
const { calculateNextDueDate } = require('./recurringTaskService');
|
const {
|
||||||
|
calculateNextDueDate,
|
||||||
|
} = require('../tasks/recurringTaskService');
|
||||||
const nextDue = calculateNextDueDate(
|
const nextDue = calculateNextDueDate(
|
||||||
task,
|
task,
|
||||||
task.habit_last_completion_at || task.created_at
|
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 { Op } = require('sequelize');
|
||||||
const { logError } = require('./logService');
|
const { logError } = require('../../services/logService');
|
||||||
const {
|
const {
|
||||||
shouldSendInAppNotification,
|
shouldSendInAppNotification,
|
||||||
shouldSendTelegramNotification,
|
shouldSendTelegramNotification,
|
||||||
} = require('../utils/notificationPreferences');
|
} = require('../../utils/notificationPreferences');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to check for due and overdue projects
|
* 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 {
|
const {
|
||||||
getTaskTodayMoveCount,
|
getTaskTodayMoveCount,
|
||||||
getTaskTodayMoveCounts,
|
getTaskTodayMoveCounts,
|
||||||
} = require('../../../services/taskEventService');
|
} = require('../taskEventService');
|
||||||
const taskRepository = require('../../../repositories/TaskRepository');
|
const taskRepository = require('../repository');
|
||||||
|
|
||||||
// Sort tags alphabetically by name (case-insensitive)
|
// Sort tags alphabetically by name (case-insensitive)
|
||||||
function sortTags(tags) {
|
function sortTags(tags) {
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
const { Task, Notification, User } = require('../models');
|
const { Task, Notification, User } = require('../../models');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { logError } = require('./logService');
|
const { logError } = require('../../services/logService');
|
||||||
const {
|
const {
|
||||||
shouldSendInAppNotification,
|
shouldSendInAppNotification,
|
||||||
shouldSendTelegramNotification,
|
shouldSendTelegramNotification,
|
||||||
} = require('../utils/notificationPreferences');
|
} = require('../../utils/notificationPreferences');
|
||||||
|
|
||||||
async function checkDeferredTasks() {
|
async function checkDeferredTasks() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
const { Task, Notification, User } = require('../models');
|
const { Task, Notification, User } = require('../../models');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const { logError } = require('./logService');
|
const { logError } = require('../../services/logService');
|
||||||
const {
|
const {
|
||||||
shouldSendInAppNotification,
|
shouldSendInAppNotification,
|
||||||
shouldSendTelegramNotification,
|
shouldSendTelegramNotification,
|
||||||
} = require('../utils/notificationPreferences');
|
} = require('../../utils/notificationPreferences');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to check for due and overdue tasks
|
* Service to check for due and overdue tasks
|
||||||
|
|
@ -6,7 +6,7 @@ const {
|
||||||
getTaskCompletionTime,
|
getTaskCompletionTime,
|
||||||
getUserProductivityMetrics,
|
getUserProductivityMetrics,
|
||||||
getTaskActivitySummary,
|
getTaskActivitySummary,
|
||||||
} = require('../../services/taskEventService');
|
} = require('./taskEventService');
|
||||||
const { logError } = require('../../services/logService');
|
const { logError } = require('../../services/logService');
|
||||||
const router = express.Router();
|
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 { Task } = require('../../../models');
|
||||||
const { Op } = require('sequelize');
|
const { Op } = require('sequelize');
|
||||||
const taskRepository = require('../../../repositories/TaskRepository');
|
const taskRepository = require('../repository');
|
||||||
const { logError } = require('../../../services/logService');
|
const { logError } = require('../../../services/logService');
|
||||||
|
|
||||||
async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) {
|
async function checkAndUpdateParentTaskCompletion(parentTaskId, userId) {
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
const { Task } = require('../../../models');
|
const { Task } = require('../../../models');
|
||||||
const taskRepository = require('../../../repositories/TaskRepository');
|
const taskRepository = require('../repository');
|
||||||
const {
|
const { calculateNextDueDate } = require('../recurringTaskService');
|
||||||
calculateNextDueDate,
|
|
||||||
} = require('../../../services/recurringTaskService');
|
|
||||||
const {
|
const {
|
||||||
processDueDateForResponse,
|
processDueDateForResponse,
|
||||||
getSafeTimezone,
|
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