tududi/backend/routes/projects.js
2025-08-06 19:49:27 +03:00

558 lines
18 KiB
JavaScript

const express = require('express');
const multer = require('multer');
const path = require('path');
const { getConfig } = require('../config/config');
const config = getConfig();
const fs = require('fs');
const { Project, Task, Tag, Area, Note, sequelize } = require('../models');
const { Op } = require('sequelize');
const { extractUidFromSlug } = require('../utils/slug-utils');
const { validateTagName } = require('../utils/validation');
const router = express.Router();
// Helper function to safely format dates
const formatDate = (date) => {
if (!date) return null;
try {
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) return null;
return dateObj.toISOString();
} catch (error) {
return null;
}
};
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadDir = path.join(config.uploadPath, 'projects');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, 'project-' + uniqueSuffix + path.extname(file.originalname));
},
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB limit
},
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(
path.extname(file.originalname).toLowerCase()
);
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed!'));
}
},
});
// Helper function to update project tags
async function updateProjectTags(project, tagsData, userId) {
if (!tagsData) return;
// Validate and filter tag names
const validTagNames = [];
const invalidTags = [];
for (const tag of tagsData) {
const validation = validateTagName(tag.name);
if (validation.valid) {
// Check for duplicates
if (!validTagNames.includes(validation.name)) {
validTagNames.push(validation.name);
}
} else {
invalidTags.push({ name: tag.name, error: validation.error });
}
}
// If there are invalid tags, throw an error
if (invalidTags.length > 0) {
throw new Error(
`Invalid tag names: ${invalidTags.map((t) => `"${t.name}" (${t.error})`).join(', ')}`
);
}
if (validTagNames.length === 0) {
await project.setTags([]);
return;
}
// Find existing tags
const existingTags = await Tag.findAll({
where: { user_id: userId, name: validTagNames },
});
// Create new tags
const existingTagNames = existingTags.map((tag) => tag.name);
const newTagNames = validTagNames.filter(
(name) => !existingTagNames.includes(name)
);
const createdTags = await Promise.all(
newTagNames.map((name) => Tag.create({ name, user_id: userId }))
);
// Set all tags to project
const allTags = [...existingTags, ...createdTags];
await project.setTags(allTags);
}
// POST /api/upload/project-image
router.post('/upload/project-image', upload.single('image'), (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
}
// Return the relative URL that can be accessed from the frontend
const imageUrl = `/api/uploads/projects/${req.file.filename}`;
res.json({ imageUrl });
} catch (error) {
console.error('Error uploading image:', error);
res.status(500).json({ error: 'Failed to upload image' });
}
});
// GET /api/projects
router.get('/projects', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { active, pin_to_sidebar, area_id, area } = req.query;
let whereClause = { user_id: req.session.userId };
// Filter by active status
if (active === 'true') {
whereClause.active = true;
} else if (active === 'false') {
whereClause.active = false;
}
// Filter by pinned status
if (pin_to_sidebar === 'true') {
whereClause.pin_to_sidebar = true;
} else if (pin_to_sidebar === 'false') {
whereClause.pin_to_sidebar = false;
}
// Filter by area - support both numeric area_id and uid-slug area
if (area && area !== '') {
// Extract uid from uid-slug format
const uid = extractUidFromSlug(area);
if (uid) {
const areaRecord = await Area.findOne({
where: { uid: uid, user_id: req.session.userId },
attributes: ['id'],
});
if (areaRecord) {
whereClause.area_id = areaRecord.id;
}
}
} else if (area_id && area_id !== '') {
// Legacy support for numeric area_id
whereClause.area_id = area_id;
}
const projects = await Project.findAll({
where: whereClause,
include: [
{
model: Task,
required: false,
attributes: ['id', 'status'],
},
{
model: Area,
required: false,
attributes: ['name'],
},
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
order: [['name', 'ASC']],
});
const { grouped } = req.query;
// Calculate task status counts for each project
const taskStatusCounts = {};
const enhancedProjects = projects.map((project) => {
const tasks = project.Tasks || [];
const taskStatus = {
total: tasks.length,
done: tasks.filter((t) => t.status === 2).length,
in_progress: tasks.filter((t) => t.status === 1).length,
not_started: tasks.filter((t) => t.status === 0).length,
};
taskStatusCounts[project.id] = taskStatus;
const projectJson = project.toJSON();
return {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to 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,
};
});
// If grouped=true, return grouped format
if (grouped === 'true') {
const groupedProjects = {};
enhancedProjects.forEach((project) => {
const areaName = project.Area ? project.Area.name : 'No Area';
if (!groupedProjects[areaName]) {
groupedProjects[areaName] = [];
}
groupedProjects[areaName].push(project);
});
res.json(groupedProjects);
} else {
res.json({
projects: enhancedProjects,
});
}
} catch (error) {
console.error('Error fetching projects:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/project/:id (supports both numeric ID and uid-slug)
router.get('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const identifier = req.params.id;
let whereClause;
// Check if identifier is numeric (regular ID) or uid-slug
if (/^\d+$/.test(identifier)) {
// It's a numeric ID
whereClause = {
id: parseInt(identifier),
user_id: req.session.userId,
};
} else {
// It's a uid-slug, extract the uid
const uid = extractUidFromSlug(identifier);
if (!uid) {
return res
.status(400)
.json({ error: 'Invalid project identifier' });
}
whereClause = { uid: uid, user_id: req.session.userId };
}
const project = await Project.findOne({
where: whereClause,
include: [
{
model: Task,
required: false,
where: {
parent_task_id: null,
// Include ALL tasks regardless of status for client-side filtering
},
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',
'title',
'content',
'created_at',
'updated_at',
],
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
},
{ model: Area, required: false, attributes: ['id', 'name'] },
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
});
if (!project) {
return res.status(404).json({ error: 'Project not found' });
}
const projectJson = project.toJSON();
// Normalize task data to match frontend expectations
const normalizedTasks = projectJson.Tasks
? projectJson.Tasks.map((task) => {
const normalizedTask = {
...task,
tags: task.Tags || [], // Normalize Tags to tags for each task
subtasks: task.Subtasks || [], // Normalize Subtasks to subtasks for each task
due_date: task.due_date
? typeof task.due_date === 'string'
? task.due_date.split('T')[0]
: task.due_date.toISOString().split('T')[0]
: null,
};
// Remove the original Tags and Subtasks properties to avoid confusion
delete normalizedTask.Tags;
delete normalizedTask.Subtasks;
return normalizedTask;
})
: [];
// Normalize note data to match frontend expectations
const normalizedNotes = projectJson.Notes
? projectJson.Notes.map((note) => {
const normalizedNote = {
...note,
tags: note.Tags || [], // Normalize Tags to tags for each note
};
// Remove the original Tags property to avoid confusion
delete normalizedNote.Tags;
return normalizedNote;
})
: [];
const result = {
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
Tasks: normalizedTasks, // Keep as Tasks (capital T) to match expected structure
Notes: normalizedNotes, // Include normalized notes with tags
due_date_at: formatDate(project.due_date_at),
};
res.json(result);
} catch (error) {
console.error('Error fetching project:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/project
router.post('/project', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const {
name,
description,
area_id,
active,
priority,
due_date_at,
image_url,
tags,
Tags,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Project name is required' });
}
const projectData = {
name: name.trim(),
description: description || '',
area_id: area_id || null,
active: active !== undefined ? active : true,
pin_to_sidebar: false,
priority: priority || null,
due_date_at: due_date_at || null,
image_url: image_url || null,
user_id: req.session.userId,
};
const project = await Project.create(projectData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
});
const projectJson = projectWithAssociations.toJSON();
res.status(201).json({
...projectJson,
uid: projectWithAssociations.uid, // Explicitly include uid
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at),
});
} catch (error) {
console.error('Error creating project:', error);
res.status(400).json({
error: 'There was a problem creating the project.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
});
// PATCH /api/project/:id
router.patch('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
const {
name,
description,
area_id,
active,
pin_to_sidebar,
priority,
due_date_at,
image_url,
tags,
Tags,
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const updateData = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (area_id !== undefined) updateData.area_id = area_id;
if (active !== undefined) updateData.active = active;
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;
await project.update(updateData);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
include: [
{
model: Tag,
attributes: ['id', 'name', 'uid'],
through: { attributes: [] },
},
],
});
const projectJson = projectWithAssociations.toJSON();
res.json({
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at),
});
} catch (error) {
console.error('Error updating project:', error);
res.status(400).json({
error: 'There was a problem updating the project.',
details: error.errors
? error.errors.map((e) => e.message)
: [error.message],
});
}
});
// DELETE /api/project/:id
router.delete('/project/:id', async (req, res) => {
try {
if (!req.session || !req.session.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const project = await Project.findOne({
where: { id: req.params.id, user_id: req.session.userId },
});
if (!project) {
return res.status(404).json({ error: 'Project not found.' });
}
await project.destroy();
res.json({ message: 'Project successfully deleted' });
} catch (error) {
console.error('Error deleting project:', error);
res.status(400).json({
error: 'There was a problem deleting the project.',
});
}
});
module.exports = router;