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 { sendPushNotificationToUsers } from './push.service.js'; import { logger } from '../utils/logger.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) if (assignedUserIds && Array.isArray(assignedUserIds) && assignedUserIds.length > 0) { try { await sendPushNotificationToUsers( assignedUserIds, { title: 'Nová úloha', body: `Bola vám priradená úloha: ${title}`, icon: '/icon-192.png', badge: '/badge-72.png', data: { url: '/todos', todoId: newTodo.id }, }, userId // exclude creator ); } catch (error) { logger.error('Failed to send push notifications for new todo', error); } } 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); // Find newly assigned users (not in existing list) const newlyAssignedUserIds = assignedUserIds.filter( id => !existingUserIds.includes(id) ); // Send push notifications to newly assigned users if (newlyAssignedUserIds.length > 0) { try { await sendPushNotificationToUsers( newlyAssignedUserIds, { title: 'Priradená úloha', body: `Bola vám priradená úloha: ${updated.title}`, icon: '/icon-192.png', badge: '/badge-72.png', data: { url: '/todos', todoId: todoId }, }, updatedByUserId // exclude user making the change ); } catch (error) { logger.error('Failed to send push notifications for updated todo', error); } } } } 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, }; };