Create todo-notification.service.js with: - notifyNewTodoAssignment(): push notification for new todo assignments - notifyUpdatedTodoAssignment(): push notification for updated assignments todo.service.js now delegates to the notification service instead of containing inline push notification logic with error handling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
538 lines
15 KiB
JavaScript
538 lines
15 KiB
JavaScript
import { db } from '../config/database.js';
|
|
import { todos, todoUsers, notes, projects, companies, users } from '../db/schema.js';
|
|
import { eq, desc, ilike, or, and, inArray, lt, ne, sql } from 'drizzle-orm';
|
|
import { NotFoundError } from '../utils/errors.js';
|
|
import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js';
|
|
import { notifyNewTodoAssignment, notifyUpdatedTodoAssignment } from './todo-notification.service.js';
|
|
import {
|
|
logTodoCreated, logTodoDeleted, logTodoCompleted, logTodoUpdated, logTodoUncompleted,
|
|
} from './audit.service.js';
|
|
|
|
/**
|
|
* Get all todos
|
|
* Optionally filter by search, project, company, assigned user, or status
|
|
* For members: returns only todos they are assigned to
|
|
*/
|
|
export const getAllTodos = async (filters = {}, userId = null, userRole = null) => {
|
|
const { searchTerm, projectId, companyId, assignedTo, status, priority } = filters;
|
|
|
|
// Pre membera filtruj len todos kde je priradeny
|
|
let accessibleTodoIds = null;
|
|
if (userRole && userRole !== 'admin' && userId) {
|
|
accessibleTodoIds = await getAccessibleResourceIds('todo', userId);
|
|
// Ak member nema pristup k ziadnym todos, vrat prazdne pole
|
|
if (accessibleTodoIds.length === 0) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Build query conditions
|
|
const conditions = [];
|
|
|
|
// Member access filter - only todos they are assigned to
|
|
if (accessibleTodoIds !== null) {
|
|
conditions.push(inArray(todos.id, accessibleTodoIds));
|
|
}
|
|
|
|
// If filtering by assignedTo, we need additional filter
|
|
if (assignedTo) {
|
|
const todoIdsWithUser = await db
|
|
.select({ todoId: todoUsers.todoId })
|
|
.from(todoUsers)
|
|
.where(eq(todoUsers.userId, assignedTo));
|
|
|
|
const assignedTodoIds = todoIdsWithUser.map((row) => row.todoId);
|
|
|
|
if (assignedTodoIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
conditions.push(inArray(todos.id, assignedTodoIds));
|
|
}
|
|
|
|
if (searchTerm) {
|
|
conditions.push(
|
|
or(
|
|
ilike(todos.title, `%${searchTerm}%`),
|
|
ilike(todos.description, `%${searchTerm}%`)
|
|
)
|
|
);
|
|
}
|
|
|
|
if (projectId) {
|
|
conditions.push(eq(todos.projectId, projectId));
|
|
}
|
|
|
|
if (companyId) {
|
|
conditions.push(eq(todos.companyId, companyId));
|
|
}
|
|
|
|
if (status) {
|
|
conditions.push(eq(todos.status, status));
|
|
}
|
|
|
|
if (priority) {
|
|
conditions.push(eq(todos.priority, priority));
|
|
}
|
|
|
|
let query = db.select().from(todos);
|
|
|
|
if (conditions.length > 0) {
|
|
query = query.where(and(...conditions));
|
|
}
|
|
|
|
const result = await query.orderBy(desc(todos.createdAt));
|
|
|
|
// Fetch assigned users for all todos
|
|
if (result.length > 0) {
|
|
const resultTodoIds = result.map(todo => todo.id);
|
|
const assignedUsersData = await db
|
|
.select({
|
|
todoId: todoUsers.todoId,
|
|
odUserId: users.id,
|
|
username: users.username,
|
|
firstName: users.firstName,
|
|
lastName: users.lastName,
|
|
})
|
|
.from(todoUsers)
|
|
.innerJoin(users, eq(todoUsers.userId, users.id))
|
|
.where(inArray(todoUsers.todoId, resultTodoIds));
|
|
|
|
// Group assigned users by todoId
|
|
const usersByTodoId = {};
|
|
for (const row of assignedUsersData) {
|
|
if (!usersByTodoId[row.todoId]) {
|
|
usersByTodoId[row.todoId] = [];
|
|
}
|
|
usersByTodoId[row.todoId].push({
|
|
id: row.odUserId,
|
|
username: row.username,
|
|
firstName: row.firstName,
|
|
lastName: row.lastName,
|
|
});
|
|
}
|
|
|
|
// Attach assigned users to each todo
|
|
return result.map(todo => ({
|
|
...todo,
|
|
assignedUsers: usersByTodoId[todo.id] || [],
|
|
}));
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/**
|
|
* Get todo by ID
|
|
*/
|
|
export const getTodoById = async (todoId) => {
|
|
const [todo] = await db
|
|
.select()
|
|
.from(todos)
|
|
.where(eq(todos.id, todoId))
|
|
.limit(1);
|
|
|
|
if (!todo) {
|
|
throw new NotFoundError('Todo nenájdené');
|
|
}
|
|
|
|
return todo;
|
|
};
|
|
|
|
/**
|
|
* Create new todo
|
|
* @param {string} userId - ID of user creating the todo
|
|
* @param {object} data - Todo data including assignedUserIds array
|
|
*/
|
|
export const createTodo = async (userId, data, auditContext = null) => {
|
|
const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data;
|
|
|
|
// Verify project exists if provided
|
|
if (projectId) {
|
|
const [project] = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId))
|
|
.limit(1);
|
|
|
|
if (!project) {
|
|
throw new NotFoundError('Projekt nenájdený');
|
|
}
|
|
}
|
|
|
|
// Verify company exists if provided
|
|
if (companyId) {
|
|
const [company] = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.limit(1);
|
|
|
|
if (!company) {
|
|
throw new NotFoundError('Firma nenájdená');
|
|
}
|
|
}
|
|
|
|
// Verify assigned users exist if provided
|
|
if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) {
|
|
const existingUsers = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(inArray(users.id, assignedUserIds));
|
|
|
|
if (existingUsers.length !== assignedUserIds.length) {
|
|
throw new NotFoundError('Niektorí používatelia neboli nájdení');
|
|
}
|
|
}
|
|
|
|
// Create the todo
|
|
const [newTodo] = await db
|
|
.insert(todos)
|
|
.values({
|
|
title,
|
|
description: description || null,
|
|
projectId: projectId || null,
|
|
companyId: companyId || null,
|
|
status: status || 'pending',
|
|
priority: priority || 'medium',
|
|
dueDate: dueDate ? new Date(dueDate) : null,
|
|
createdBy: userId,
|
|
})
|
|
.returning();
|
|
|
|
// Assign users to the todo
|
|
if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) {
|
|
const todoUserInserts = assignedUserIds.map((assignedUserId) => ({
|
|
todoId: newTodo.id,
|
|
userId: assignedUserId,
|
|
assignedBy: userId,
|
|
}));
|
|
|
|
await db.insert(todoUsers).values(todoUserInserts);
|
|
}
|
|
|
|
// Send push notifications to assigned users (excluding creator)
|
|
await notifyNewTodoAssignment(assignedUserIds, title, newTodo.id, userId);
|
|
|
|
if (auditContext) {
|
|
await logTodoCreated(auditContext.userId, newTodo.id, newTodo.title, auditContext.ipAddress, auditContext.userAgent);
|
|
}
|
|
|
|
return newTodo;
|
|
};
|
|
|
|
/**
|
|
* Update todo
|
|
* @param {string} todoId - ID of todo to update
|
|
* @param {object} data - Updated data including assignedUserIds array
|
|
* @param {string} updatedByUserId - ID of user making the update (for notifications)
|
|
*/
|
|
export const updateTodo = async (todoId, data, updatedByUserId = null, auditContext = null) => {
|
|
const todo = await getTodoById(todoId);
|
|
|
|
const { title, description, projectId, companyId, assignedUserIds, status, priority, dueDate } = data;
|
|
|
|
// Verify project exists if being changed
|
|
if (projectId !== undefined && projectId !== null && projectId !== todo.projectId) {
|
|
const [project] = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.id, projectId))
|
|
.limit(1);
|
|
|
|
if (!project) {
|
|
throw new NotFoundError('Projekt nenájdený');
|
|
}
|
|
}
|
|
|
|
// Verify company exists if being changed
|
|
if (companyId !== undefined && companyId !== null && companyId !== todo.companyId) {
|
|
const [company] = await db
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, companyId))
|
|
.limit(1);
|
|
|
|
if (!company) {
|
|
throw new NotFoundError('Firma nenájdená');
|
|
}
|
|
}
|
|
|
|
// Verify assigned users exist if being changed
|
|
if (assignedUserIds !== undefined && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) {
|
|
const existingUsers = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(inArray(users.id, assignedUserIds));
|
|
|
|
if (existingUsers.length !== assignedUserIds.length) {
|
|
throw new NotFoundError('Niektorí používatelia neboli nájdení');
|
|
}
|
|
}
|
|
|
|
// Set completedAt when status is changed to 'completed'
|
|
let completedAt = todo.completedAt;
|
|
if (status === 'completed' && todo.status !== 'completed') {
|
|
completedAt = new Date();
|
|
} else if (status && status !== 'completed') {
|
|
completedAt = null;
|
|
}
|
|
|
|
const [updated] = await db
|
|
.update(todos)
|
|
.set({
|
|
title: title !== undefined ? title : todo.title,
|
|
description: description !== undefined ? description : todo.description,
|
|
projectId: projectId !== undefined ? projectId : todo.projectId,
|
|
companyId: companyId !== undefined ? companyId : todo.companyId,
|
|
status: status !== undefined ? status : todo.status,
|
|
priority: priority !== undefined ? priority : todo.priority,
|
|
dueDate: dueDate !== undefined ? (dueDate ? new Date(dueDate) : null) : todo.dueDate,
|
|
completedAt,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(todos.id, todoId))
|
|
.returning();
|
|
|
|
// Update assigned users if provided
|
|
if (assignedUserIds !== undefined) {
|
|
// Get existing assigned users before deleting
|
|
const existingAssignments = await db
|
|
.select({ userId: todoUsers.userId })
|
|
.from(todoUsers)
|
|
.where(eq(todoUsers.todoId, todoId));
|
|
const existingUserIds = existingAssignments.map(a => a.userId);
|
|
|
|
// Delete existing assignments
|
|
await db.delete(todoUsers).where(eq(todoUsers.todoId, todoId));
|
|
|
|
// Create new assignments
|
|
if (Array.isArray(assignedUserIds) && assignedUserIds.length > 0) {
|
|
const todoUserInserts = assignedUserIds.map((userId) => ({
|
|
todoId: todoId,
|
|
userId: userId,
|
|
assignedBy: updatedByUserId,
|
|
}));
|
|
|
|
await db.insert(todoUsers).values(todoUserInserts);
|
|
|
|
// Send push notifications to newly assigned users
|
|
const newlyAssignedUserIds = assignedUserIds.filter(
|
|
id => !existingUserIds.includes(id)
|
|
);
|
|
await notifyUpdatedTodoAssignment(newlyAssignedUserIds, updated.title, todoId, updatedByUserId);
|
|
}
|
|
}
|
|
|
|
if (auditContext) {
|
|
// Detect status changes for specific audit events
|
|
if (status === 'completed' && todo.status !== 'completed') {
|
|
await logTodoCompleted(auditContext.userId, todoId, todo.title, auditContext.ipAddress, auditContext.userAgent);
|
|
} else if (status && status !== 'completed' && todo.status === 'completed') {
|
|
await logTodoUncompleted(auditContext.userId, todoId, todo.title, auditContext.ipAddress, auditContext.userAgent);
|
|
} else {
|
|
await logTodoUpdated(auditContext.userId, todoId, { title: todo.title }, { title: updated.title }, auditContext.ipAddress, auditContext.userAgent);
|
|
}
|
|
}
|
|
|
|
return updated;
|
|
};
|
|
|
|
/**
|
|
* Delete todo
|
|
*/
|
|
export const deleteTodo = async (todoId, auditContext = null) => {
|
|
const todo = await getTodoById(todoId); // Check if exists
|
|
|
|
await db.delete(todos).where(eq(todos.id, todoId));
|
|
|
|
if (auditContext) {
|
|
await logTodoDeleted(auditContext.userId, todoId, todo.title, auditContext.ipAddress, auditContext.userAgent);
|
|
}
|
|
|
|
return { success: true, message: 'Todo bolo odstránené' };
|
|
};
|
|
|
|
/**
|
|
* Get todo with related data (notes, project, company, assigned users)
|
|
* Optimized to use joins and Promise.all to avoid N+1 query problem
|
|
*/
|
|
export const getTodoWithRelations = async (todoId) => {
|
|
// Fetch todo with project and company in single query using joins
|
|
const [todoResult] = await db
|
|
.select({
|
|
todo: todos,
|
|
project: projects,
|
|
company: companies,
|
|
})
|
|
.from(todos)
|
|
.leftJoin(projects, eq(todos.projectId, projects.id))
|
|
.leftJoin(companies, eq(todos.companyId, companies.id))
|
|
.where(eq(todos.id, todoId))
|
|
.limit(1);
|
|
|
|
if (!todoResult) {
|
|
throw new NotFoundError('Todo nenajdene');
|
|
}
|
|
|
|
// Batch fetch for assigned users and notes in parallel
|
|
const [assignedUsers, todoNotes] = await Promise.all([
|
|
db
|
|
.select({
|
|
id: users.id,
|
|
username: users.username,
|
|
firstName: users.firstName,
|
|
lastName: users.lastName,
|
|
assignedAt: todoUsers.assignedAt,
|
|
})
|
|
.from(todoUsers)
|
|
.innerJoin(users, eq(todoUsers.userId, users.id))
|
|
.where(eq(todoUsers.todoId, todoId)),
|
|
|
|
db
|
|
.select()
|
|
.from(notes)
|
|
.where(eq(notes.todoId, todoId))
|
|
.orderBy(desc(notes.createdAt)),
|
|
]);
|
|
|
|
return {
|
|
...todoResult.todo,
|
|
project: todoResult.project,
|
|
company: todoResult.company,
|
|
assignedUsers,
|
|
notes: todoNotes,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get todos by project ID
|
|
*/
|
|
export const getTodosByProjectId = async (projectId) => {
|
|
return await db
|
|
.select()
|
|
.from(todos)
|
|
.where(eq(todos.projectId, projectId))
|
|
.orderBy(desc(todos.createdAt));
|
|
};
|
|
|
|
/**
|
|
* Get todos by company ID
|
|
*/
|
|
export const getTodosByCompanyId = async (companyId) => {
|
|
return await db
|
|
.select()
|
|
.from(todos)
|
|
.where(eq(todos.companyId, companyId))
|
|
.orderBy(desc(todos.createdAt));
|
|
};
|
|
|
|
/**
|
|
* Get todos assigned to a user
|
|
*/
|
|
export const getTodosByUserId = async (userId) => {
|
|
const todoIdsWithUser = await db
|
|
.select({ todoId: todoUsers.todoId })
|
|
.from(todoUsers)
|
|
.where(eq(todoUsers.userId, userId));
|
|
|
|
const todoIds = todoIdsWithUser.map((row) => row.todoId);
|
|
|
|
if (todoIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return await db
|
|
.select()
|
|
.from(todos)
|
|
.where(inArray(todos.id, todoIds))
|
|
.orderBy(desc(todos.createdAt));
|
|
};
|
|
|
|
/**
|
|
* Get count of overdue todos for a user
|
|
* Overdue = dueDate < now AND status !== 'completed'
|
|
* For members: only counts todos they are assigned to
|
|
*/
|
|
export const getOverdueCount = async (userId, userRole) => {
|
|
const now = new Date();
|
|
|
|
// Get accessible todo IDs for non-admin users
|
|
let accessibleTodoIds = null;
|
|
if (userRole && userRole !== 'admin') {
|
|
accessibleTodoIds = await getAccessibleResourceIds('todo', userId);
|
|
if (accessibleTodoIds.length === 0) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
const conditions = [
|
|
lt(todos.dueDate, now),
|
|
ne(todos.status, 'completed'),
|
|
];
|
|
|
|
if (accessibleTodoIds !== null) {
|
|
conditions.push(inArray(todos.id, accessibleTodoIds));
|
|
}
|
|
|
|
const result = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(todos)
|
|
.where(and(...conditions));
|
|
|
|
return result[0]?.count || 0;
|
|
};
|
|
|
|
/**
|
|
* Get count of todos created by user that were completed but not yet notified
|
|
* Returns todos where createdBy = userId AND status = 'completed' AND completedNotifiedAt IS NULL
|
|
*/
|
|
export const getCompletedByMeCount = async (userId) => {
|
|
const result = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(todos)
|
|
.where(
|
|
and(
|
|
eq(todos.createdBy, userId),
|
|
eq(todos.status, 'completed'),
|
|
sql`${todos.completedNotifiedAt} IS NULL`
|
|
)
|
|
);
|
|
|
|
return result[0]?.count || 0;
|
|
};
|
|
|
|
/**
|
|
* Mark all completed todos created by user as notified
|
|
* Called when user opens the Todos page
|
|
*/
|
|
export const markCompletedAsNotified = async (userId) => {
|
|
await db
|
|
.update(todos)
|
|
.set({ completedNotifiedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(todos.createdBy, userId),
|
|
eq(todos.status, 'completed'),
|
|
sql`${todos.completedNotifiedAt} IS NULL`
|
|
)
|
|
);
|
|
|
|
return { success: true };
|
|
};
|
|
|
|
/**
|
|
* Get combined todo counts for sidebar badges
|
|
*/
|
|
export const getTodoCounts = async (userId, userRole) => {
|
|
const [overdueCount, completedByMeCount] = await Promise.all([
|
|
getOverdueCount(userId, userRole),
|
|
getCompletedByMeCount(userId),
|
|
]);
|
|
|
|
return {
|
|
overdueCount,
|
|
completedByMeCount,
|
|
};
|
|
};
|