376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
'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 {
|
|
getSafeTimezone,
|
|
processDueDateForResponse,
|
|
} = require('../../utils/timezone-utils');
|
|
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;
|
|
|
|
const isActiveStatus =
|
|
project.status === 'planned' ||
|
|
project.status === 'in_progress';
|
|
const activeTaskCount =
|
|
taskStatus.in_progress + taskStatus.not_started;
|
|
const isStalled = isActiveStatus && activeTaskCount === 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,
|
|
is_stalled: isStalled,
|
|
};
|
|
});
|
|
|
|
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, userTimezone) {
|
|
const validatedUid = validateUid(uid);
|
|
const project =
|
|
await projectsRepository.findByUidWithIncludes(validatedUid);
|
|
|
|
if (!project) {
|
|
throw new NotFoundError('Project not found');
|
|
}
|
|
|
|
const safeTimezone = getSafeTimezone(userTimezone);
|
|
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: processDueDateForResponse(
|
|
task.due_date,
|
|
safeTimezone
|
|
),
|
|
};
|
|
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;
|