import { db } from '../config/database.js'; import { timeEntries, projects, todos, companies, users } from '../db/schema.js'; import { eq, and, gte, lte, desc, sql } from 'drizzle-orm'; import { NotFoundError, BadRequestError } from '../utils/errors.js'; /** * Start a new time entry */ export const startTimeEntry = async (userId, data) => { const { projectId, todoId, companyId, description } = data; // Check if user already has a running time entry const [existingRunning] = await db .select() .from(timeEntries) .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) .limit(1); if (existingRunning) { throw new BadRequestError('Máte už spustený časovač. Prosím zastavte ho pred spustením nového.'); } // 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 todo exists if provided if (todoId) { const [todo] = await db .select() .from(todos) .where(eq(todos.id, todoId)) .limit(1); if (!todo) { throw new NotFoundError('Todo 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á'); } } const [newEntry] = await db .insert(timeEntries) .values({ userId, projectId: projectId || null, todoId: todoId || null, companyId: companyId || null, description: description || null, startTime: new Date(), endTime: null, duration: null, isRunning: true, isEdited: false, }) .returning(); return newEntry; }; /** * Stop a running time entry */ export const stopTimeEntry = async (entryId, userId, data = {}) => { const { projectId, todoId, companyId, description } = data; const entry = await getTimeEntryById(entryId); // Verify ownership if (entry.userId !== userId) { throw new BadRequestError('Nemáte oprávnenie zastaviť tento časovač'); } if (!entry.isRunning) { throw new BadRequestError('Tento časovač už nie je spustený'); } const endTime = new Date(); const durationInMinutes = Math.round((endTime - new Date(entry.startTime)) / 60000); // Verify related entities 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ý'); } } if (todoId) { const [todo] = await db .select() .from(todos) .where(eq(todos.id, todoId)) .limit(1); if (!todo) { throw new NotFoundError('Todo nenájdené'); } } if (companyId) { const [company] = await db .select() .from(companies) .where(eq(companies.id, companyId)) .limit(1); if (!company) { throw new NotFoundError('Firma nenájdená'); } } const [updated] = await db .update(timeEntries) .set({ endTime, duration: durationInMinutes, isRunning: false, projectId: projectId !== undefined ? projectId : entry.projectId, todoId: todoId !== undefined ? todoId : entry.todoId, companyId: companyId !== undefined ? companyId : entry.companyId, description: description !== undefined ? description : entry.description, updatedAt: new Date(), }) .where(eq(timeEntries.id, entryId)) .returning(); return updated; }; /** * Get running time entry for a user */ export const getRunningTimeEntry = async (userId) => { const [running] = await db .select() .from(timeEntries) .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) .limit(1); return running || null; }; /** * Get time entry by ID */ export const getTimeEntryById = async (entryId) => { const [entry] = await db .select() .from(timeEntries) .where(eq(timeEntries.id, entryId)) .limit(1); if (!entry) { throw new NotFoundError('Záznam nenájdený'); } return entry; }; /** * Get all time entries for a user with optional filters */ export const getAllTimeEntries = async (userId, filters = {}) => { const { projectId, todoId, companyId, startDate, endDate } = filters; let query = db.select().from(timeEntries).where(eq(timeEntries.userId, userId)); const conditions = [eq(timeEntries.userId, userId)]; if (projectId) { conditions.push(eq(timeEntries.projectId, projectId)); } if (todoId) { conditions.push(eq(timeEntries.todoId, todoId)); } if (companyId) { conditions.push(eq(timeEntries.companyId, companyId)); } if (startDate) { conditions.push(gte(timeEntries.startTime, new Date(startDate))); } if (endDate) { conditions.push(lte(timeEntries.startTime, new Date(endDate))); } if (conditions.length > 0) { query = query.where(and(...conditions)); } const result = await query.orderBy(desc(timeEntries.startTime)); return result; }; /** * Get time entries for a specific month */ export const getMonthlyTimeEntries = async (userId, year, month) => { const startDate = new Date(year, month - 1, 1); const endDate = new Date(year, month, 0, 23, 59, 59, 999); return await db .select() .from(timeEntries) .where( and( eq(timeEntries.userId, userId), gte(timeEntries.startTime, startDate), lte(timeEntries.startTime, endDate) ) ) .orderBy(desc(timeEntries.startTime)); }; /** * Update time entry */ export const updateTimeEntry = async (entryId, userId, data) => { const entry = await getTimeEntryById(entryId); // Verify ownership if (entry.userId !== userId) { throw new BadRequestError('Nemáte oprávnenie upraviť tento záznam'); } if (entry.isRunning) { throw new BadRequestError('Nemôžete upraviť bežiaci časovač. Najprv ho zastavte.'); } const { startTime, endTime, projectId, todoId, companyId, description } = data; // Verify related entities if being changed if (projectId !== undefined && projectId !== null) { const [project] = await db .select() .from(projects) .where(eq(projects.id, projectId)) .limit(1); if (!project) { throw new NotFoundError('Projekt nenájdený'); } } if (todoId !== undefined && todoId !== null) { const [todo] = await db .select() .from(todos) .where(eq(todos.id, todoId)) .limit(1); if (!todo) { throw new NotFoundError('Todo nenájdené'); } } if (companyId !== undefined && companyId !== null) { const [company] = await db .select() .from(companies) .where(eq(companies.id, companyId)) .limit(1); if (!company) { throw new NotFoundError('Firma nenájdená'); } } // Calculate new duration if times are changed let newDuration = entry.duration; const newStartTime = startTime ? new Date(startTime) : new Date(entry.startTime); const newEndTime = endTime ? new Date(endTime) : (entry.endTime ? new Date(entry.endTime) : null); if (newEndTime) { newDuration = Math.round((newEndTime - newStartTime) / 60000); } const [updated] = await db .update(timeEntries) .set({ startTime: newStartTime, endTime: newEndTime, duration: newDuration, projectId: projectId !== undefined ? projectId : entry.projectId, todoId: todoId !== undefined ? todoId : entry.todoId, companyId: companyId !== undefined ? companyId : entry.companyId, description: description !== undefined ? description : entry.description, isEdited: true, updatedAt: new Date(), }) .where(eq(timeEntries.id, entryId)) .returning(); return updated; }; /** * Delete time entry */ export const deleteTimeEntry = async (entryId, userId) => { const entry = await getTimeEntryById(entryId); // Verify ownership if (entry.userId !== userId) { throw new BadRequestError('Nemáte oprávnenie odstrániť tento záznam'); } if (entry.isRunning) { throw new BadRequestError('Nemôžete odstrániť bežiaci časovač. Najprv ho zastavte.'); } await db.delete(timeEntries).where(eq(timeEntries.id, entryId)); return { success: true, message: 'Záznam bol odstránený' }; }; /** * Get time entry with related data (project, todo, company) */ export const getTimeEntryWithRelations = async (entryId) => { const entry = await getTimeEntryById(entryId); // Get project if exists let project = null; if (entry.projectId) { [project] = await db .select() .from(projects) .where(eq(projects.id, entry.projectId)) .limit(1); } // Get todo if exists let todo = null; if (entry.todoId) { [todo] = await db .select() .from(todos) .where(eq(todos.id, entry.todoId)) .limit(1); } // Get company if exists let company = null; if (entry.companyId) { [company] = await db .select() .from(companies) .where(eq(companies.id, entry.companyId)) .limit(1); } return { ...entry, project, todo, company, }; }; /** * Get monthly statistics for a user */ export const getMonthlyStats = async (userId, year, month) => { const entries = await getMonthlyTimeEntries(userId, year, month); // Total time in minutes const totalMinutes = entries.reduce((sum, entry) => { return sum + (entry.duration || 0); }, 0); // Count of days worked const uniqueDays = new Set( entries .filter((e) => !e.isRunning) .map((e) => new Date(e.startTime).toDateString()) ).size; // Time by project const byProject = {}; entries.forEach((entry) => { if (entry.projectId && entry.duration) { if (!byProject[entry.projectId]) { byProject[entry.projectId] = 0; } byProject[entry.projectId] += entry.duration; } }); // Time by company const byCompany = {}; entries.forEach((entry) => { if (entry.companyId && entry.duration) { if (!byCompany[entry.companyId]) { byCompany[entry.companyId] = 0; } byCompany[entry.companyId] += entry.duration; } }); return { totalMinutes, totalHours: Math.floor(totalMinutes / 60), remainingMinutes: totalMinutes % 60, daysWorked: uniqueDays, averagePerDay: uniqueDays > 0 ? Math.round(totalMinutes / uniqueDays) : 0, entriesCount: entries.length, byProject, byCompany, }; };