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:
@@ -22,6 +22,7 @@ import companyRoutes from './routes/company.routes.js';
|
|||||||
import projectRoutes from './routes/project.routes.js';
|
import projectRoutes from './routes/project.routes.js';
|
||||||
import todoRoutes from './routes/todo.routes.js';
|
import todoRoutes from './routes/todo.routes.js';
|
||||||
import noteRoutes from './routes/note.routes.js';
|
import noteRoutes from './routes/note.routes.js';
|
||||||
|
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ app.use('/api/companies', companyRoutes);
|
|||||||
app.use('/api/projects', projectRoutes);
|
app.use('/api/projects', projectRoutes);
|
||||||
app.use('/api/todos', todoRoutes);
|
app.use('/api/todos', todoRoutes);
|
||||||
app.use('/api/notes', noteRoutes);
|
app.use('/api/notes', noteRoutes);
|
||||||
|
app.use('/api/time-tracking', timeTrackingRoutes);
|
||||||
|
|
||||||
// Basic route
|
// Basic route
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
|||||||
246
src/controllers/time-tracking.controller.js
Normal file
246
src/controllers/time-tracking.controller.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import * as timeTrackingService from '../services/time-tracking.service.js';
|
||||||
|
import { formatErrorResponse } from '../utils/errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new time entry
|
||||||
|
* POST /api/time-tracking/start
|
||||||
|
*/
|
||||||
|
export const startTimeEntry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { projectId, todoId, companyId, description } = req.body;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.startTimeEntry(userId, {
|
||||||
|
projectId,
|
||||||
|
todoId,
|
||||||
|
companyId,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
message: 'Časovač bol spustený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running time entry
|
||||||
|
* POST /api/time-tracking/:entryId/stop
|
||||||
|
*/
|
||||||
|
export const stopTimeEntry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { entryId } = req.params;
|
||||||
|
const { projectId, todoId, companyId, description } = req.body;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.stopTimeEntry(entryId, userId, {
|
||||||
|
projectId,
|
||||||
|
todoId,
|
||||||
|
companyId,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
message: 'Časovač bol zastavený',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get running time entry for current user
|
||||||
|
* GET /api/time-tracking/running
|
||||||
|
*/
|
||||||
|
export const getRunningTimeEntry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.getRunningTimeEntry(userId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all time entries for current user with filters
|
||||||
|
* GET /api/time-tracking?projectId=xxx&todoId=xxx&companyId=xxx&startDate=xxx&endDate=xxx
|
||||||
|
*/
|
||||||
|
export const getAllTimeEntries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { projectId, todoId, companyId, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
projectId,
|
||||||
|
todoId,
|
||||||
|
companyId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = await timeTrackingService.getAllTimeEntries(userId, filters);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: entries.length,
|
||||||
|
data: entries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time entries for a specific month
|
||||||
|
* GET /api/time-tracking/month/:year/:month
|
||||||
|
*/
|
||||||
|
export const getMonthlyTimeEntries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { year, month } = req.params;
|
||||||
|
|
||||||
|
const entries = await timeTrackingService.getMonthlyTimeEntries(
|
||||||
|
userId,
|
||||||
|
parseInt(year),
|
||||||
|
parseInt(month)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
count: entries.length,
|
||||||
|
data: entries,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time entry by ID
|
||||||
|
* GET /api/time-tracking/:entryId
|
||||||
|
*/
|
||||||
|
export const getTimeEntryById = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { entryId } = req.params;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.getTimeEntryById(entryId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time entry with related data
|
||||||
|
* GET /api/time-tracking/:entryId/details
|
||||||
|
*/
|
||||||
|
export const getTimeEntryWithRelations = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { entryId } = req.params;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.getTimeEntryWithRelations(entryId);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update time entry
|
||||||
|
* PATCH /api/time-tracking/:entryId
|
||||||
|
*/
|
||||||
|
export const updateTimeEntry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { entryId } = req.params;
|
||||||
|
const { startTime, endTime, projectId, todoId, companyId, description } = req.body;
|
||||||
|
|
||||||
|
const entry = await timeTrackingService.updateTimeEntry(entryId, userId, {
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
projectId,
|
||||||
|
todoId,
|
||||||
|
companyId,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: entry,
|
||||||
|
message: 'Záznam bol aktualizovaný',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete time entry
|
||||||
|
* DELETE /api/time-tracking/:entryId
|
||||||
|
*/
|
||||||
|
export const deleteTimeEntry = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { entryId } = req.params;
|
||||||
|
|
||||||
|
const result = await timeTrackingService.deleteTimeEntry(entryId, userId);
|
||||||
|
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get monthly statistics
|
||||||
|
* GET /api/time-tracking/stats/monthly/:year/:month
|
||||||
|
*/
|
||||||
|
export const getMonthlyStats = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId;
|
||||||
|
const { year, month } = req.params;
|
||||||
|
|
||||||
|
const stats = await timeTrackingService.getMonthlyStats(
|
||||||
|
userId,
|
||||||
|
parseInt(year),
|
||||||
|
parseInt(month)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorResponse = formatErrorResponse(error, process.env.NODE_ENV === 'development');
|
||||||
|
res.status(error.statusCode || 500).json(errorResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
23
src/db/migrations/0004_add_time_entries_table.sql
Normal file
23
src/db/migrations/0004_add_time_entries_table.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "time_entries" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"project_id" uuid,
|
||||||
|
"todo_id" uuid,
|
||||||
|
"company_id" uuid,
|
||||||
|
"start_time" timestamp NOT NULL,
|
||||||
|
"end_time" timestamp,
|
||||||
|
"duration" integer,
|
||||||
|
"description" text,
|
||||||
|
"is_running" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_edited" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_todo_id_todos_id_fk" FOREIGN KEY ("todo_id") REFERENCES "public"."todos"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action;
|
||||||
@@ -190,3 +190,20 @@ export const timesheets = pgTable('timesheets', {
|
|||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Time Entries table - sledovanie odpracovaného času používateľov
|
||||||
|
export const timeEntries = pgTable('time_entries', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), // kto trackuje čas
|
||||||
|
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }), // na akom projekte (voliteľné)
|
||||||
|
todoId: uuid('todo_id').references(() => todos.id, { onDelete: 'set null' }), // na akom todo (voliteľné)
|
||||||
|
companyId: uuid('company_id').references(() => companies.id, { onDelete: 'set null' }), // pre akú firmu (voliteľné)
|
||||||
|
startTime: timestamp('start_time').notNull(), // kedy začal trackovať
|
||||||
|
endTime: timestamp('end_time'), // kedy skončil (null ak ešte beží)
|
||||||
|
duration: integer('duration'), // trvanie v minútach
|
||||||
|
description: text('description'), // popis práce
|
||||||
|
isRunning: boolean('is_running').default(false).notNull(), // či práve beží
|
||||||
|
isEdited: boolean('is_edited').default(false).notNull(), // či bol editovaný
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|||||||
91
src/routes/time-tracking.routes.js
Normal file
91
src/routes/time-tracking.routes.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import * as timeTrackingController from '../controllers/time-tracking.controller.js';
|
||||||
|
import { authenticate } from '../middlewares/auth/authMiddleware.js';
|
||||||
|
import { validateBody, validateParams } from '../middlewares/security/validateInput.js';
|
||||||
|
import {
|
||||||
|
startTimeEntrySchema,
|
||||||
|
stopTimeEntrySchema,
|
||||||
|
updateTimeEntrySchema,
|
||||||
|
} from '../validators/crm.validators.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// All time tracking routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time Tracking management
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Start new time entry
|
||||||
|
router.post('/start', validateBody(startTimeEntrySchema), timeTrackingController.startTimeEntry);
|
||||||
|
|
||||||
|
// Stop running time entry
|
||||||
|
router.post(
|
||||||
|
'/:entryId/stop',
|
||||||
|
validateParams(z.object({ entryId: z.string().uuid() })),
|
||||||
|
validateBody(stopTimeEntrySchema),
|
||||||
|
timeTrackingController.stopTimeEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get running time entry
|
||||||
|
router.get('/running', timeTrackingController.getRunningTimeEntry);
|
||||||
|
|
||||||
|
// Get all time entries with filters
|
||||||
|
router.get('/', timeTrackingController.getAllTimeEntries);
|
||||||
|
|
||||||
|
// Get monthly time entries
|
||||||
|
router.get(
|
||||||
|
'/month/:year/:month',
|
||||||
|
validateParams(
|
||||||
|
z.object({
|
||||||
|
year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-ciferné číslo'),
|
||||||
|
month: z.string().regex(/^(0?[1-9]|1[0-2])$/, 'Mesiac musí byť číslo 1-12'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
timeTrackingController.getMonthlyTimeEntries
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get monthly statistics
|
||||||
|
router.get(
|
||||||
|
'/stats/monthly/:year/:month',
|
||||||
|
validateParams(
|
||||||
|
z.object({
|
||||||
|
year: z.string().regex(/^\d{4}$/, 'Rok musí byť 4-ciferné číslo'),
|
||||||
|
month: z.string().regex(/^(0?[1-9]|1[0-2])$/, 'Mesiac musí byť číslo 1-12'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
timeTrackingController.getMonthlyStats
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get time entry by ID
|
||||||
|
router.get(
|
||||||
|
'/:entryId',
|
||||||
|
validateParams(z.object({ entryId: z.string().uuid() })),
|
||||||
|
timeTrackingController.getTimeEntryById
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get time entry with relations
|
||||||
|
router.get(
|
||||||
|
'/:entryId/details',
|
||||||
|
validateParams(z.object({ entryId: z.string().uuid() })),
|
||||||
|
timeTrackingController.getTimeEntryWithRelations
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update time entry
|
||||||
|
router.patch(
|
||||||
|
'/:entryId',
|
||||||
|
validateParams(z.object({ entryId: z.string().uuid() })),
|
||||||
|
validateBody(updateTimeEntrySchema),
|
||||||
|
timeTrackingController.updateTimeEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete time entry
|
||||||
|
router.delete(
|
||||||
|
'/:entryId',
|
||||||
|
validateParams(z.object({ entryId: z.string().uuid() })),
|
||||||
|
timeTrackingController.deleteTimeEntry
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -105,3 +105,27 @@ export const updateNoteSchema = z.object({
|
|||||||
contactId: z.string().uuid('Neplatný formát contact ID').optional().or(z.literal('').or(z.null())),
|
contactId: z.string().uuid('Neplatný formát contact ID').optional().or(z.literal('').or(z.null())),
|
||||||
reminderDate: z.string().optional().or(z.literal('').or(z.null())),
|
reminderDate: z.string().optional().or(z.literal('').or(z.null())),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Time Tracking validators
|
||||||
|
export const startTimeEntrySchema = z.object({
|
||||||
|
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')),
|
||||||
|
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')),
|
||||||
|
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stopTimeEntrySchema = z.object({
|
||||||
|
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('')),
|
||||||
|
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('')),
|
||||||
|
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('')),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTimeEntrySchema = z.object({
|
||||||
|
startTime: z.string().optional(),
|
||||||
|
endTime: z.string().optional(),
|
||||||
|
projectId: z.string().uuid('Neplatný formát project ID').optional().or(z.literal('').or(z.null())),
|
||||||
|
todoId: z.string().uuid('Neplatný formát todo ID').optional().or(z.literal('').or(z.null())),
|
||||||
|
companyId: z.string().uuid('Neplatný formát company ID').optional().or(z.literal('').or(z.null())),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user