From dfcf8056f38ab87e36a573e85a9761379fa202e4 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Mon, 24 Nov 2025 09:10:04 +0100 Subject: [PATCH] add time tracker with stats --- src/services/time-tracking.service.js | 61 +++++++++++++++++++++------ src/validators/crm.validators.js | 49 +++++++++++++++------ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/src/services/time-tracking.service.js b/src/services/time-tracking.service.js index a4f96b2..b3c5dd2 100644 --- a/src/services/time-tracking.service.js +++ b/src/services/time-tracking.service.js @@ -3,11 +3,29 @@ 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'; +// Helpers to normalize optional payload fields +const normalizeOptionalId = (value) => { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + return value; +}; + +const normalizeOptionalText = (value) => { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +}; + /** * Start a new time entry */ export const startTimeEntry = async (userId, data) => { - const { projectId, todoId, companyId, description } = data; + const projectId = normalizeOptionalId(data.projectId); + const todoId = normalizeOptionalId(data.todoId); + const companyId = normalizeOptionalId(data.companyId); + const description = normalizeOptionalText(data.description); // Check if user already has a running time entry const [existingRunning] = await db @@ -16,8 +34,20 @@ export const startTimeEntry = async (userId, data) => { .where(and(eq(timeEntries.userId, userId), eq(timeEntries.isRunning, true))) .limit(1); + // Automatically stop existing running timer if (existingRunning) { - throw new BadRequestError('Máte už spustený časovač. Prosím zastavte ho pred spustením nového.'); + const endTime = new Date(); + const durationInMinutes = Math.round((endTime - new Date(existingRunning.startTime)) / 60000); + + await db + .update(timeEntries) + .set({ + endTime, + duration: durationInMinutes, + isRunning: false, + updatedAt: new Date(), + }) + .where(eq(timeEntries.id, existingRunning.id)); } // Verify project exists if provided @@ -63,10 +93,10 @@ export const startTimeEntry = async (userId, data) => { .insert(timeEntries) .values({ userId, - projectId: projectId || null, - todoId: todoId || null, - companyId: companyId || null, - description: description || null, + projectId: projectId ?? null, + todoId: todoId ?? null, + companyId: companyId ?? null, + description: description ?? null, startTime: new Date(), endTime: null, duration: null, @@ -82,7 +112,10 @@ export const startTimeEntry = async (userId, data) => { * Stop a running time entry */ export const stopTimeEntry = async (entryId, userId, data = {}) => { - const { projectId, todoId, companyId, description } = data; + const projectId = normalizeOptionalId(data.projectId); + const todoId = normalizeOptionalId(data.todoId); + const companyId = normalizeOptionalId(data.companyId); + const description = normalizeOptionalText(data.description); const entry = await getTimeEntryById(entryId); @@ -98,7 +131,7 @@ export const stopTimeEntry = async (entryId, userId, data = {}) => { const endTime = new Date(); const durationInMinutes = Math.round((endTime - new Date(entry.startTime)) / 60000); - // Verify related entities if provided + // Verify related entities if provided (skip validation for null/undefined) if (projectId) { const [project] = await db .select() @@ -256,10 +289,14 @@ export const updateTimeEntry = async (entryId, userId, data) => { throw new BadRequestError('Nemôžete upraviť bežiaci časovač. Najprv ho zastavte.'); } - const { startTime, endTime, projectId, todoId, companyId, description } = data; + const { startTime, endTime } = data; + const projectId = normalizeOptionalId(data.projectId); + const todoId = normalizeOptionalId(data.todoId); + const companyId = normalizeOptionalId(data.companyId); + const description = normalizeOptionalText(data.description); // Verify related entities if being changed - if (projectId !== undefined && projectId !== null) { + if (projectId) { const [project] = await db .select() .from(projects) @@ -271,7 +308,7 @@ export const updateTimeEntry = async (entryId, userId, data) => { } } - if (todoId !== undefined && todoId !== null) { + if (todoId) { const [todo] = await db .select() .from(todos) @@ -283,7 +320,7 @@ export const updateTimeEntry = async (entryId, userId, data) => { } } - if (companyId !== undefined && companyId !== null) { + if (companyId) { const [company] = await db .select() .from(companies) diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js index defc7b5..0be6bca 100644 --- a/src/validators/crm.validators.js +++ b/src/validators/crm.validators.js @@ -107,25 +107,50 @@ export const updateNoteSchema = z.object({ }); // Time Tracking validators +const optionalUuid = (message) => + z + .preprocess( + (val) => { + if (val === undefined) return undefined; + if (val === null || val === '') return null; + return val; + }, + z.string().uuid(message).nullable() + ) + .optional(); + +const optionalDescription = z + .preprocess( + (val) => { + if (val === undefined) return undefined; + if (val === null) return null; + if (typeof val !== 'string') return val; + const trimmed = val.trim(); + return trimmed === '' ? null : trimmed; + }, + z.string().max(1000).nullable() + ) + .optional(); + 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(), + projectId: optionalUuid('Neplatný formát project ID'), + todoId: optionalUuid('Neplatný formát todo ID'), + companyId: optionalUuid('Neplatný formát company ID'), + description: optionalDescription, }); 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(), + projectId: optionalUuid('Neplatný formát project ID'), + todoId: optionalUuid('Neplatný formát todo ID'), + companyId: optionalUuid('Neplatný formát company ID'), + description: optionalDescription, }); 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(), + projectId: optionalUuid('Neplatný formát project ID'), + todoId: optionalUuid('Neplatný formát todo ID'), + companyId: optionalUuid('Neplatný formát company ID'), + description: optionalDescription, });