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 todoRoutes from './routes/todo.routes.js';
|
||||
import noteRoutes from './routes/note.routes.js';
|
||||
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -82,6 +83,7 @@ app.use('/api/companies', companyRoutes);
|
||||
app.use('/api/projects', projectRoutes);
|
||||
app.use('/api/todos', todoRoutes);
|
||||
app.use('/api/notes', noteRoutes);
|
||||
app.use('/api/time-tracking', timeTrackingRoutes);
|
||||
|
||||
// Basic route
|
||||
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(),
|
||||
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())),
|
||||
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