diff --git a/backend/app.js b/backend/app.js index e28e070..cacdfe4 100644 --- a/backend/app.js +++ b/backend/app.js @@ -182,6 +182,7 @@ const registerApiRoutes = (basePath) => { app.use(basePath, require('./routes/telegram')); app.use(basePath, require('./routes/quotes')); app.use(basePath, require('./routes/task-events')); + app.use(basePath, require('./routes/task-attachments')); app.use(`${basePath}/search`, require('./routes/search')); app.use(`${basePath}/views`, require('./routes/views')); app.use(`${basePath}/notifications`, require('./routes/notifications')); diff --git a/backend/migrations/20251128000001-create-task-attachments.js b/backend/migrations/20251128000001-create-task-attachments.js new file mode 100644 index 0000000..17f7036 --- /dev/null +++ b/backend/migrations/20251128000001-create-task-attachments.js @@ -0,0 +1,88 @@ +'use strict'; + +const { + safeCreateTable, + safeAddIndex, +} = require('../utils/migration-utils'); + +module.exports = { + async up(queryInterface, Sequelize) { + await safeCreateTable(queryInterface, 'task_attachments', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + task_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + onDelete: 'CASCADE', + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + original_filename: { + type: Sequelize.STRING, + allowNull: false, + }, + stored_filename: { + type: Sequelize.STRING, + allowNull: false, + }, + file_size: { + type: Sequelize.INTEGER, + allowNull: false, + }, + mime_type: { + type: Sequelize.STRING, + allowNull: false, + }, + file_path: { + type: Sequelize.STRING, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + + // Add indexes using safeAddIndex + await safeAddIndex(queryInterface, 'task_attachments', ['task_id'], { + name: 'task_attachments_task_id', + }); + + await safeAddIndex(queryInterface, 'task_attachments', ['user_id'], { + name: 'task_attachments_user_id', + }); + + await safeAddIndex(queryInterface, 'task_attachments', ['uid'], { + name: 'task_attachments_uid', + unique: true, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable('task_attachments'); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index c371c29..83a56a7 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -35,6 +35,7 @@ const ApiToken = require('./api_token')(sequelize); const Setting = require('./setting')(sequelize); const Notification = require('./notification')(sequelize); const RecurringCompletion = require('./recurringCompletion')(sequelize); +const TaskAttachment = require('./task_attachment')(sequelize); User.hasMany(Area, { foreignKey: 'user_id' }); Area.belongsTo(User, { foreignKey: 'user_id' }); @@ -145,6 +146,12 @@ ApiToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); User.hasMany(Notification, { foreignKey: 'user_id', as: 'Notifications' }); Notification.belongsTo(User, { foreignKey: 'user_id', as: 'User' }); +// TaskAttachment associations +User.hasMany(TaskAttachment, { foreignKey: 'user_id' }); +TaskAttachment.belongsTo(User, { foreignKey: 'user_id' }); +Task.hasMany(TaskAttachment, { foreignKey: 'task_id', as: 'Attachments' }); +TaskAttachment.belongsTo(Task, { foreignKey: 'task_id' }); + module.exports = { sequelize, User, @@ -163,4 +170,5 @@ module.exports = { Setting, Notification, RecurringCompletion, + TaskAttachment, }; diff --git a/backend/models/task_attachment.js b/backend/models/task_attachment.js new file mode 100644 index 0000000..9cd88ff --- /dev/null +++ b/backend/models/task_attachment.js @@ -0,0 +1,87 @@ +const { DataTypes } = require('sequelize'); +const { uid } = require('../utils/uid'); + +module.exports = (sequelize) => { + const TaskAttachment = sequelize.define( + 'TaskAttachment', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + uid: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + defaultValue: uid, + }, + task_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'tasks', + key: 'id', + }, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + original_filename: { + type: DataTypes.STRING, + allowNull: false, + }, + stored_filename: { + type: DataTypes.STRING, + allowNull: false, + }, + file_size: { + type: DataTypes.INTEGER, + allowNull: false, + }, + mime_type: { + type: DataTypes.STRING, + allowNull: false, + }, + file_path: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'task_attachments', + indexes: [ + { + fields: ['task_id'], + }, + { + fields: ['user_id'], + }, + { + fields: ['uid'], + unique: true, + }, + ], + } + ); + + // Define associations + TaskAttachment.associate = function (models) { + TaskAttachment.belongsTo(models.Task, { + foreignKey: 'task_id', + as: 'Task', + }); + + TaskAttachment.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'User', + }); + }; + + return TaskAttachment; +}; diff --git a/backend/routes/task-attachments.js b/backend/routes/task-attachments.js new file mode 100644 index 0000000..d204cdf --- /dev/null +++ b/backend/routes/task-attachments.js @@ -0,0 +1,271 @@ +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 { TaskAttachment, Task } = require('../models'); +const { uid } = require('../utils/uid'); +const { logError } = require('../services/logService'); +const { + validateFileType, + deleteFileFromDisk, + getFileUrl, +} = require('../utils/attachment-utils'); +const { getAuthenticatedUserId } = require('../utils/request-utils'); + +const router = express.Router(); + +// Ensure authenticated +router.use((req, res, next) => { + const userId = getAuthenticatedUserId(req); + if (!userId) { + return res.status(401).json({ error: 'Authentication required' }); + } + req.authUserId = userId; + next(); +}); + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + const uploadDir = path.join(config.uploadPath, 'tasks'); + 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, 'task-' + uniqueSuffix + path.extname(file.originalname)); + }, +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 10 * 1024 * 1024, // 10MB limit + }, + fileFilter: function (req, file, cb) { + if (validateFileType(file.mimetype)) { + return cb(null, true); + } else { + cb(new Error('File type not allowed')); + } + }, +}); + +// Upload attachment to task +router.post( + '/upload/task-attachment', + upload.single('file'), + async (req, res) => { + try { + const { taskUid } = req.body; + const userId = req.authUserId; + + if (!taskUid) { + // Clean up uploaded file + if (req.file) { + await deleteFileFromDisk(req.file.path); + } + return res.status(400).json({ error: 'Task UID is required' }); + } + + // Find task + const task = await Task.findOne({ where: { uid: taskUid } }); + if (!task) { + // Clean up uploaded file + if (req.file) { + await deleteFileFromDisk(req.file.path); + } + return res.status(404).json({ error: 'Task not found' }); + } + + // Check if user owns the task + if (task.user_id !== userId) { + // Clean up uploaded file + if (req.file) { + await deleteFileFromDisk(req.file.path); + } + return res + .status(403) + .json({ error: 'Not authorized to upload to this task' }); + } + + // Check attachment count limit (20 max) + const attachmentCount = await TaskAttachment.count({ + where: { task_id: task.id }, + }); + + if (attachmentCount >= 20) { + // Clean up uploaded file + if (req.file) { + await deleteFileFromDisk(req.file.path); + } + return res.status(400).json({ + error: 'Maximum 20 attachments allowed per task', + }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // Create attachment record + const attachment = await TaskAttachment.create({ + uid: uid(), + task_id: task.id, + user_id: userId, + original_filename: req.file.originalname, + stored_filename: req.file.filename, + file_size: req.file.size, + mime_type: req.file.mimetype, + file_path: `tasks/${req.file.filename}`, + }); + + // Return attachment with file URL + const attachmentData = { + ...attachment.toJSON(), + file_url: getFileUrl(req.file.filename), + }; + + res.status(201).json(attachmentData); + } catch (error) { + logError('Error uploading attachment:', error); + + // Clean up uploaded file on error + if (req.file) { + await deleteFileFromDisk(req.file.path); + } + + res.status(500).json({ + error: 'Failed to upload attachment', + details: error.message, + }); + } + } +); + +// Get all attachments for a task +router.get('/tasks/:taskUid/attachments', async (req, res) => { + try { + const { taskUid } = req.params; + const userId = req.authUserId; + + // Find task + const task = await Task.findOne({ where: { uid: taskUid } }); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Check if user owns the task + if (task.user_id !== userId) { + return res + .status(403) + .json({ error: 'Not authorized to view this task' }); + } + + // Get attachments + const attachments = await TaskAttachment.findAll({ + where: { task_id: task.id }, + order: [['created_at', 'ASC']], + }); + + // Add file URLs + const attachmentsWithUrls = attachments.map((att) => ({ + ...att.toJSON(), + file_url: getFileUrl(att.stored_filename), + })); + + res.json(attachmentsWithUrls); + } catch (error) { + logError('Error fetching attachments:', error); + res.status(500).json({ + error: 'Failed to fetch attachments', + details: error.message, + }); + } +}); + +// Delete an attachment +router.delete('/tasks/:taskUid/attachments/:attachmentUid', async (req, res) => { + try { + const { taskUid, attachmentUid } = req.params; + const userId = req.authUserId; + + // Find task + const task = await Task.findOne({ where: { uid: taskUid } }); + if (!task) { + return res.status(404).json({ error: 'Task not found' }); + } + + // Check if user owns the task + if (task.user_id !== userId) { + return res + .status(403) + .json({ error: 'Not authorized to modify this task' }); + } + + // Find attachment + const attachment = await TaskAttachment.findOne({ + where: { uid: attachmentUid, task_id: task.id }, + }); + + if (!attachment) { + return res.status(404).json({ error: 'Attachment not found' }); + } + + // Delete file from disk + const filePath = path.join(config.uploadPath, attachment.file_path); + await deleteFileFromDisk(filePath); + + // Delete database record + await attachment.destroy(); + + res.json({ message: 'Attachment deleted successfully' }); + } catch (error) { + logError('Error deleting attachment:', error); + res.status(500).json({ + error: 'Failed to delete attachment', + details: error.message, + }); + } +}); + +// Download an attachment +router.get('/attachments/:attachmentUid/download', async (req, res) => { + try { + const { attachmentUid } = req.params; + const userId = req.authUserId; + + // Find attachment + const attachment = await TaskAttachment.findOne({ + where: { uid: attachmentUid }, + include: [{ model: Task, required: true }], + }); + + if (!attachment) { + return res.status(404).json({ error: 'Attachment not found' }); + } + + // Check if user owns the task + if (attachment.Task.user_id !== userId) { + return res + .status(403) + .json({ error: 'Not authorized to download this file' }); + } + + // Send file + const filePath = path.join(config.uploadPath, attachment.file_path); + res.download(filePath, attachment.original_filename); + } catch (error) { + logError('Error downloading attachment:', error); + res.status(500).json({ + error: 'Failed to download attachment', + details: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/utils/attachment-utils.js b/backend/utils/attachment-utils.js new file mode 100644 index 0000000..24508d2 --- /dev/null +++ b/backend/utils/attachment-utils.js @@ -0,0 +1,124 @@ +const path = require('path'); +const fs = require('fs').promises; +const { logError } = require('../services/logService'); + +// Allowed MIME types and their extensions +const ALLOWED_TYPES = { + // Documents + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + ['.docx'], + 'text/plain': ['.txt'], + 'text/markdown': ['.md'], + // Images + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/gif': ['.gif'], + 'image/svg+xml': ['.svg'], + 'image/webp': ['.webp'], + // Spreadsheets + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [ + '.xlsx', + ], + 'text/csv': ['.csv'], + // Archives + 'application/zip': ['.zip'], + 'application/x-zip-compressed': ['.zip'], +}; + +/** + * Validate if file type is allowed + */ +function validateFileType(mimetype) { + return !!ALLOWED_TYPES[mimetype]; +} + +/** + * Get file extension from MIME type + */ +function getExtensionFromMimeType(mimetype) { + const extensions = ALLOWED_TYPES[mimetype]; + return extensions ? extensions[0] : ''; +} + +/** + * Format file size in human-readable format + */ +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} + +/** + * Check if file is an image + */ +function isImageFile(mimetype) { + return mimetype.startsWith('image/'); +} + +/** + * Check if file is a PDF + */ +function isPdfFile(mimetype) { + return mimetype === 'application/pdf'; +} + +/** + * Check if file is a text file + */ +function isTextFile(mimetype) { + return mimetype.startsWith('text/'); +} + +/** + * Delete file from disk safely + */ +async function deleteFileFromDisk(filepath) { + try { + await fs.unlink(filepath); + return true; + } catch (error) { + logError('Error deleting file from disk:', error); + return false; + } +} + +/** + * Ensure upload directory exists + */ +async function ensureUploadDir(dir) { + try { + await fs.mkdir(dir, { recursive: true }); + return true; + } catch (error) { + logError('Error creating upload directory:', error); + return false; + } +} + +/** + * Get file URL for serving + */ +function getFileUrl(storedFilename) { + return `/api/uploads/tasks/${storedFilename}`; +} + +module.exports = { + ALLOWED_TYPES, + validateFileType, + getExtensionFromMimeType, + formatFileSize, + isImageFile, + isPdfFile, + isTextFile, + deleteFileFromDisk, + ensureUploadDir, + getFileUrl, +}; diff --git a/frontend/components/Shared/AttachmentListItem.tsx b/frontend/components/Shared/AttachmentListItem.tsx new file mode 100644 index 0000000..7daf4da --- /dev/null +++ b/frontend/components/Shared/AttachmentListItem.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { TrashIcon, ArrowDownTrayIcon, EyeIcon } from '@heroicons/react/24/outline'; +import { Attachment } from '../../entities/Attachment'; +import { formatFileSize, canPreviewInline } from '../../utils/attachmentsService'; +import FileIcon from './FileIcon'; + +interface AttachmentListItemProps { + attachment: Attachment; + onDelete: (attachment: Attachment) => void; + onDownload: (attachment: Attachment) => void; + onPreview?: (attachment: Attachment) => void; + showPreview?: boolean; +} + +const AttachmentListItem: React.FC = ({ + attachment, + onDelete, + onDownload, + onPreview, + showPreview = true, +}) => { + const canPreview = canPreviewInline(attachment.mime_type); + + return ( +
+
+ +
+

+ {attachment.original_filename} +

+

+ {formatFileSize(attachment.file_size)} +

+
+
+
+ {showPreview && canPreview && onPreview && ( + + )} + + +
+
+ ); +}; + +export default AttachmentListItem; diff --git a/frontend/components/Shared/AttachmentPreview.tsx b/frontend/components/Shared/AttachmentPreview.tsx new file mode 100644 index 0000000..2c7c6d2 --- /dev/null +++ b/frontend/components/Shared/AttachmentPreview.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import { Attachment } from '../../entities/Attachment'; +import { getAttachmentType } from '../../utils/attachmentsService'; + +interface AttachmentPreviewProps { + attachment: Attachment; + maxHeight?: string; + className?: string; +} + +const AttachmentPreview: React.FC = ({ + attachment, + maxHeight = '400px', + className = '', +}) => { + const [textContent, setTextContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const type = getAttachmentType(attachment.mime_type); + + useEffect(() => { + if (type === 'text' && attachment.file_url) { + setLoading(true); + fetch(attachment.file_url, { credentials: 'include' }) + .then((res) => res.text()) + .then((text) => { + setTextContent(text); + setLoading(false); + }) + .catch((err) => { + setError('Failed to load file content'); + setLoading(false); + }); + } + }, [attachment.file_url, type]); + + if (!attachment.file_url) { + return null; + } + + if (type === 'image') { + return ( +
+ {attachment.original_filename} +
+ ); + } + + if (type === 'pdf') { + return ( +
+