* Fix test issues * Fix uid issue * Fix id wrong param * Fix test issues * fixup! Fix test issues * fixup! fixup! Fix test issues * fixup! fixup! fixup! Fix test issues * fixup! fixup! fixup! fixup! Fix test issues
713 lines
24 KiB
JavaScript
713 lines
24 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,
|
|
User,
|
|
Permission,
|
|
sequelize,
|
|
} = require('../models');
|
|
const permissionsService = require('../services/permissionsService');
|
|
const { Op } = require('sequelize');
|
|
const { extractUidFromSlug } = require('../utils/slug-utils');
|
|
const { validateTagName } = require('../services/tagsService');
|
|
const { uid } = require('../utils/uid');
|
|
const { logError } = require('../services/logService');
|
|
const router = express.Router();
|
|
const { getAuthenticatedUserId } = require('../utils/request-utils');
|
|
|
|
router.use((req, res, next) => {
|
|
const userId = getAuthenticatedUserId(req);
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
req.authUserId = userId;
|
|
next();
|
|
});
|
|
const { hasAccess } = require('../middleware/authorize');
|
|
const { requireAuth } = require('../middleware/auth');
|
|
|
|
// 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: 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!'));
|
|
}
|
|
},
|
|
});
|
|
|
|
// 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',
|
|
requireAuth,
|
|
upload.single('image'),
|
|
(req, res) => {
|
|
try {
|
|
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) {
|
|
logError('Error uploading image:', error);
|
|
res.status(500).json({ error: 'Failed to upload image' });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.get('/projects', async (req, res) => {
|
|
try {
|
|
const { state, active, pin_to_sidebar, area_id, area } = req.query;
|
|
|
|
// Base: owned or shared projects
|
|
const ownedOrShared =
|
|
await permissionsService.ownershipOrPermissionWhere(
|
|
'project',
|
|
req.authUserId
|
|
);
|
|
let whereClause = ownedOrShared;
|
|
|
|
// Filter by state (new primary filter)
|
|
if (state && state !== 'all') {
|
|
if (Array.isArray(state)) {
|
|
whereClause.state = { [Op.in]: state };
|
|
} else {
|
|
whereClause.state = state;
|
|
}
|
|
}
|
|
|
|
// Legacy support for active filter - map to states
|
|
if (active === 'true') {
|
|
whereClause.state = {
|
|
[Op.in]: ['planned', 'in_progress', 'blocked'],
|
|
};
|
|
} else if (active === 'false') {
|
|
whereClause.state = { [Op.in]: ['idea', 'completed'] };
|
|
}
|
|
|
|
// 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 },
|
|
attributes: ['id'],
|
|
});
|
|
if (areaRecord) {
|
|
// add to AND filter
|
|
whereClause = {
|
|
[Op.and]: [whereClause, { area_id: areaRecord.id }],
|
|
};
|
|
}
|
|
}
|
|
} else if (area_id && area_id !== '') {
|
|
// Legacy support for numeric area_id
|
|
whereClause = { [Op.and]: [whereClause, { area_id }] };
|
|
}
|
|
|
|
const projects = await Project.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']],
|
|
});
|
|
|
|
const { grouped } = req.query;
|
|
|
|
// Calculate task status counts and share counts for each project
|
|
const projectIds = projects.map((p) => p.id);
|
|
const projectUids = projects.map((p) => p.uid).filter(Boolean);
|
|
|
|
// Get share counts for all projects in one query using permissions table
|
|
const shareCountMap = {};
|
|
if (projectUids.length > 0) {
|
|
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,
|
|
});
|
|
|
|
// Create a map of project_uid to share_count
|
|
const uidToCountMap = {};
|
|
shareCounts.forEach((item) => {
|
|
uidToCountMap[item.resource_uid] = parseInt(item.count, 10);
|
|
});
|
|
|
|
// Map uids back to project ids
|
|
projects.forEach((project) => {
|
|
if (project.uid && uidToCountMap[project.uid]) {
|
|
shareCountMap[project.id] = uidToCountMap[project.uid];
|
|
}
|
|
});
|
|
}
|
|
|
|
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();
|
|
const shareCount = shareCountMap[project.id] || 0;
|
|
|
|
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,
|
|
user_uid: projectJson.User?.uid,
|
|
share_count: shareCount,
|
|
is_shared: shareCount > 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) {
|
|
logError('Error fetching projects:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// GET /api/project/:uidSlug (UID-slug format only)
|
|
router.get(
|
|
'/project/:uidSlug',
|
|
hasAccess(
|
|
'ro',
|
|
'project',
|
|
async (req) => {
|
|
const uid = extractUidFromSlug(req.params.uidSlug);
|
|
// Check if project exists - return null if it doesn't (triggers 404)
|
|
const project = await Project.findOne({
|
|
where: { uid },
|
|
attributes: ['uid'],
|
|
});
|
|
return project ? project.uid : null;
|
|
},
|
|
{ notFoundMessage: 'Project not found' }
|
|
),
|
|
async (req, res) => {
|
|
try {
|
|
const uidPart = extractUidFromSlug(req.params.uidSlug);
|
|
const project = await Project.findOne({
|
|
where: { uid: uidPart },
|
|
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: [] },
|
|
},
|
|
],
|
|
});
|
|
|
|
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 || [],
|
|
subtasks: task.Subtasks || [],
|
|
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;
|
|
})
|
|
: [];
|
|
|
|
// Normalize note data to match frontend expectations
|
|
const normalizedNotes = projectJson.Notes
|
|
? projectJson.Notes.map((note) => {
|
|
const normalizedNote = {
|
|
...note,
|
|
tags: note.Tags || [],
|
|
};
|
|
delete normalizedNote.Tags;
|
|
return normalizedNote;
|
|
})
|
|
: [];
|
|
|
|
// Get share count for this project
|
|
let shareCount = 0;
|
|
if (project.uid) {
|
|
const shareCountResult = await Permission.count({
|
|
where: {
|
|
resource_type: 'project',
|
|
resource_uid: project.uid,
|
|
},
|
|
});
|
|
shareCount = shareCountResult || 0;
|
|
}
|
|
|
|
const result = {
|
|
...projectJson,
|
|
tags: 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,
|
|
};
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
logError('Error fetching project:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
}
|
|
);
|
|
|
|
router.post('/project', async (req, res) => {
|
|
try {
|
|
const {
|
|
name,
|
|
description,
|
|
area_id,
|
|
priority,
|
|
due_date_at,
|
|
image_url,
|
|
state,
|
|
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' });
|
|
}
|
|
|
|
// Generate UID explicitly to avoid Sequelize caching issues
|
|
const projectUid = uid();
|
|
|
|
const projectData = {
|
|
uid: projectUid,
|
|
name: name.trim(),
|
|
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,
|
|
state: state || 'idea',
|
|
user_id: req.authUserId,
|
|
};
|
|
|
|
// Create is always allowed for the authenticated user; project is owned by creator
|
|
const project = await Project.create(projectData);
|
|
|
|
// Update tags if provided, but don't let tag errors break project creation
|
|
try {
|
|
await updateProjectTags(project, tagsData, req.authUserId);
|
|
} catch (tagError) {
|
|
logError(
|
|
'Tag update failed, but project created successfully:',
|
|
tagError.message
|
|
);
|
|
}
|
|
|
|
res.status(201).json({
|
|
...project.toJSON(),
|
|
uid: projectUid, // Use the UID we explicitly generated
|
|
tags: [], // Start with empty tags - they can be added later
|
|
due_date_at: formatDate(project.due_date_at),
|
|
});
|
|
} catch (error) {
|
|
logError('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],
|
|
});
|
|
}
|
|
});
|
|
|
|
router.patch(
|
|
'/project/:uid',
|
|
hasAccess(
|
|
'rw',
|
|
'project',
|
|
async (req) => {
|
|
const uid = extractUidFromSlug(req.params.uid);
|
|
// Check if project exists - return null if it doesn't (triggers 404)
|
|
const project = await Project.findOne({
|
|
where: { uid },
|
|
attributes: ['uid'],
|
|
});
|
|
return project ? project.uid : null;
|
|
},
|
|
{ notFoundMessage: 'Project not found.' }
|
|
),
|
|
async (req, res) => {
|
|
try {
|
|
const project = await Project.findOne({
|
|
where: { uid: req.params.uid },
|
|
});
|
|
|
|
const {
|
|
name,
|
|
description,
|
|
area_id,
|
|
pin_to_sidebar,
|
|
priority,
|
|
due_date_at,
|
|
image_url,
|
|
state,
|
|
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 (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 (state !== undefined) updateData.state = state;
|
|
|
|
await project.update(updateData);
|
|
await updateProjectTags(project, tagsData, req.authUserId);
|
|
|
|
// Reload project with associations
|
|
const projectWithAssociations = await Project.findByPk(project.id, {
|
|
include: [
|
|
{
|
|
model: Tag,
|
|
attributes: ['id', 'name', 'uid'],
|
|
through: { attributes: [] },
|
|
},
|
|
{
|
|
model: Area,
|
|
required: false,
|
|
attributes: ['id', 'uid', 'name'],
|
|
},
|
|
],
|
|
});
|
|
|
|
const projectJson = projectWithAssociations.toJSON();
|
|
|
|
res.json({
|
|
...projectJson,
|
|
tags: projectJson.Tags || [],
|
|
due_date_at: formatDate(projectWithAssociations.due_date_at),
|
|
});
|
|
} catch (error) {
|
|
logError('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],
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
router.delete(
|
|
'/project/:uid',
|
|
requireAuth,
|
|
hasAccess(
|
|
'rw',
|
|
'project',
|
|
async (req) => {
|
|
const uid = extractUidFromSlug(req.params.uid);
|
|
// Check if project exists - return null if it doesn't (triggers 404)
|
|
const project = await Project.findOne({
|
|
where: { uid },
|
|
attributes: ['uid'],
|
|
});
|
|
return project ? project.uid : null;
|
|
},
|
|
{ notFoundMessage: 'Project not found.' }
|
|
),
|
|
async (req, res) => {
|
|
try {
|
|
const project = await Project.findOne({
|
|
where: { uid: req.params.uid },
|
|
});
|
|
|
|
// Use a transaction to ensure atomicity
|
|
await sequelize.transaction(async (transaction) => {
|
|
// Disable foreign key constraints for this operation
|
|
await sequelize.query('PRAGMA foreign_keys = OFF', {
|
|
transaction,
|
|
});
|
|
|
|
try {
|
|
// First, orphan all tasks associated with this project by setting project_id to NULL
|
|
await Task.update(
|
|
{ project_id: null },
|
|
{
|
|
where: {
|
|
project_id: project.id,
|
|
user_id: req.authUserId,
|
|
},
|
|
transaction,
|
|
}
|
|
);
|
|
|
|
// Also orphan all notes associated with this project by setting project_id to NULL
|
|
await Note.update(
|
|
{ project_id: null },
|
|
{
|
|
where: {
|
|
project_id: project.id,
|
|
user_id: req.authUserId,
|
|
},
|
|
transaction,
|
|
}
|
|
);
|
|
|
|
// Then delete the project
|
|
await project.destroy({ transaction });
|
|
} finally {
|
|
// Re-enable foreign key constraints
|
|
await sequelize.query('PRAGMA foreign_keys = ON', {
|
|
transaction,
|
|
});
|
|
}
|
|
});
|
|
|
|
res.json({ message: 'Project successfully deleted' });
|
|
} catch (error) {
|
|
logError('Error deleting project:', error);
|
|
res.status(400).json({
|
|
error: 'There was a problem deleting the project.',
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
module.exports = router;
|