tududi/backend/modules/projects/service.js

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;