Add Time Tracking backend API
Implementovaný kompletný backend pre time tracking: - Nová tabuľka time_entries s foreign keys na users, projects, todos, companies - Service layer s business logikou pre CRUD operácie - Controller pre všetky endpointy - Validačné schémy pomocou Zod - Routes s autentifikáciou a validáciou - Endpointy: * POST /api/time-tracking/start - Spustenie timeru * POST /api/time-tracking/:id/stop - Zastavenie timeru * GET /api/time-tracking/running - Získanie bežiaceho záznamu * GET /api/time-tracking/month/:year/:month - Mesačné záznamy * GET /api/time-tracking/stats/monthly/:year/:month - Mesačné štatistiky * PATCH /api/time-tracking/:id - Aktualizácia záznamu * DELETE /api/time-tracking/:id - Zmazanie záznamu - Podpora pre isEdited flag pri editácii - Kalkulácia duration v minútach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
440
src/services/time-tracking.service.js
Normal file
440
src/services/time-tracking.service.js
Normal file
@@ -0,0 +1,440 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user