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:
richardtekula
2025-11-24 06:41:39 +01:00
parent ca93b6f2d2
commit 540c1719d3
7 changed files with 843 additions and 0 deletions

View File

@@ -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) => {

View 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);
}
};

View 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;

View File

@@ -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(),
});

View 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;

View 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,
};
};

View File

@@ -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(),
});