diff --git a/src/app.js b/src/app.js index 5de6a47..110a722 100644 --- a/src/app.js +++ b/src/app.js @@ -25,7 +25,7 @@ import todoRoutes from './routes/todo.routes.js'; import timeTrackingRoutes from './routes/time-tracking.routes.js'; import noteRoutes from './routes/note.routes.js'; import auditRoutes from './routes/audit.routes.js'; -import meetingRoutes from './routes/meeting.routes.js'; +import eventRoutes from './routes/event.routes.js'; const app = express(); @@ -89,7 +89,7 @@ app.use('/api/todos', todoRoutes); app.use('/api/time-tracking', timeTrackingRoutes); app.use('/api/notes', noteRoutes); app.use('/api/audit-logs', auditRoutes); -app.use('/api/meetings', meetingRoutes); +app.use('/api/events', eventRoutes); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js new file mode 100644 index 0000000..8404206 --- /dev/null +++ b/src/controllers/event.controller.js @@ -0,0 +1,108 @@ +import * as eventService from '../services/event.service.js'; + +/** + * Get calendar data (events + todos) by month + * GET /api/events?year=2024&month=1 + */ +export const getCalendarData = async (req, res, next) => { + try { + const { year, month } = req.query; + const userId = req.userId; + const isAdmin = req.user?.role === 'admin'; + + const currentDate = new Date(); + const queryYear = year ? parseInt(year) : currentDate.getFullYear(); + const queryMonth = month ? parseInt(month) : currentDate.getMonth() + 1; + + const data = await eventService.getCalendarData(queryYear, queryMonth, userId, isAdmin); + + res.status(200).json({ + success: true, + data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get event by ID + * GET /api/events/:eventId + */ +export const getEventById = async (req, res, next) => { + try { + const { eventId } = req.params; + const userId = req.userId; + const isAdmin = req.user?.role === 'admin'; + + const event = await eventService.getEventById(eventId, userId, isAdmin); + + res.status(200).json({ + success: true, + data: event, + }); + } catch (error) { + next(error); + } +}; + +/** + * Create a new event (admin only) + * POST /api/events + */ +export const createEvent = async (req, res, next) => { + try { + const userId = req.userId; + const data = req.body; + + const event = await eventService.createEvent(userId, data); + + res.status(201).json({ + success: true, + data: event, + message: 'Event bol vytvorený', + }); + } catch (error) { + next(error); + } +}; + +/** + * Update an event (admin only) + * PUT /api/events/:eventId + */ +export const updateEvent = async (req, res, next) => { + try { + const { eventId } = req.params; + const data = req.body; + + const event = await eventService.updateEvent(eventId, data); + + res.status(200).json({ + success: true, + data: event, + message: 'Event bol upravený', + }); + } catch (error) { + next(error); + } +}; + +/** + * Delete an event (admin only) + * DELETE /api/events/:eventId + */ +export const deleteEvent = async (req, res, next) => { + try { + const { eventId } = req.params; + + const result = await eventService.deleteEvent(eventId); + + res.status(200).json({ + success: true, + message: result.message, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/meeting.controller.js b/src/controllers/meeting.controller.js deleted file mode 100644 index fb0b537..0000000 --- a/src/controllers/meeting.controller.js +++ /dev/null @@ -1,105 +0,0 @@ -import * as meetingService from '../services/meeting.service.js'; - -/** - * Get meetings by month - * GET /api/meetings?year=2024&month=1 - */ -export const getMeetingsByMonth = async (req, res, next) => { - try { - const { year, month } = req.query; - - const currentDate = new Date(); - const queryYear = year ? parseInt(year) : currentDate.getFullYear(); - const queryMonth = month ? parseInt(month) : currentDate.getMonth() + 1; - - const meetings = await meetingService.getMeetingsByMonth(queryYear, queryMonth); - - res.status(200).json({ - success: true, - count: meetings.length, - data: meetings, - }); - } catch (error) { - next(error); - } -}; - -/** - * Get meeting by ID - * GET /api/meetings/:meetingId - */ -export const getMeetingById = async (req, res, next) => { - try { - const { meetingId } = req.params; - - const meeting = await meetingService.getMeetingById(meetingId); - - res.status(200).json({ - success: true, - data: meeting, - }); - } catch (error) { - next(error); - } -}; - -/** - * Create a new meeting (admin only) - * POST /api/meetings - */ -export const createMeeting = async (req, res, next) => { - try { - const userId = req.userId; - const data = req.body; - - const meeting = await meetingService.createMeeting(userId, data); - - res.status(201).json({ - success: true, - data: meeting, - message: 'Meeting bol vytvorený', - }); - } catch (error) { - next(error); - } -}; - -/** - * Update a meeting (admin only) - * PUT /api/meetings/:meetingId - */ -export const updateMeeting = async (req, res, next) => { - try { - const { meetingId } = req.params; - const data = req.body; - - const meeting = await meetingService.updateMeeting(meetingId, data); - - res.status(200).json({ - success: true, - data: meeting, - message: 'Meeting bol upravený', - }); - } catch (error) { - next(error); - } -}; - -/** - * Delete a meeting (admin only) - * DELETE /api/meetings/:meetingId - */ -export const deleteMeeting = async (req, res, next) => { - try { - const { meetingId } = req.params; - - const result = await meetingService.deleteMeeting(meetingId); - - res.status(200).json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; diff --git a/src/db/migrations/0001_meetings_to_events.sql b/src/db/migrations/0001_meetings_to_events.sql new file mode 100644 index 0000000..4469352 --- /dev/null +++ b/src/db/migrations/0001_meetings_to_events.sql @@ -0,0 +1,21 @@ +-- Migration: Convert meetings to events with type and assigned users +-- Run this migration manually or via drizzle-kit + +-- Step 1: Rename meetings table to events +ALTER TABLE meetings RENAME TO events; + +-- Step 2: Add type column +ALTER TABLE events ADD COLUMN type TEXT NOT NULL DEFAULT 'meeting'; + +-- Step 3: Create event_users junction table +CREATE TABLE event_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + assigned_at TIMESTAMP DEFAULT NOW() NOT NULL, + UNIQUE(event_id, user_id) +); + +-- Step 4: Assign existing events to their creators +INSERT INTO event_users (event_id, user_id) +SELECT id, created_by FROM events WHERE created_by IS NOT NULL; diff --git a/src/db/schema.js b/src/db/schema.js index 683c3a3..edf200a 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -242,11 +242,12 @@ export const timesheets = pgTable('timesheets', { updatedAt: timestamp('updated_at').defaultNow().notNull(), }); -// Meetings table - meetingy/stretnutia (iba admin môže CRUD) -export const meetings = pgTable('meetings', { +// Events table - udalosti v kalendári (meeting/event) +export const events = pgTable('events', { id: uuid('id').primaryKey().defaultRandom(), title: text('title').notNull(), description: text('description'), + type: text('type').notNull().default('meeting'), // 'meeting' | 'event' start: timestamp('start', { withTimezone: true }).notNull(), end: timestamp('end', { withTimezone: true }).notNull(), createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), @@ -254,6 +255,16 @@ export const meetings = pgTable('meetings', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); +// Event Users - many-to-many medzi events a users (kto vidí udalosť) +export const eventUsers = pgTable('event_users', { + id: uuid('id').primaryKey().defaultRandom(), + eventId: uuid('event_id').references(() => events.id, { onDelete: 'cascade' }).notNull(), + userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(), + assignedAt: timestamp('assigned_at').defaultNow().notNull(), +}, (table) => ({ + eventUserUnique: unique('event_user_unique').on(table.eventId, table.userId), +})); + // Time Entries table - sledovanie odpracovaného času používateľov export const timeEntries = pgTable('time_entries', { id: uuid('id').primaryKey().defaultRandom(), diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js new file mode 100644 index 0000000..de5e8a9 --- /dev/null +++ b/src/routes/event.routes.js @@ -0,0 +1,72 @@ +import express from 'express'; +import * as eventController from '../controllers/event.controller.js'; +import { authenticate } from '../middlewares/auth/authMiddleware.js'; +import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js'; +import { createEventSchema, updateEventSchema } from '../validators/crm.validators.js'; +import { z } from 'zod'; + +const router = express.Router(); + +const monthQuerySchema = z.object({ + year: z.string().regex(/^\d{4}$/).optional(), + month: z.string().regex(/^(1[0-2]|[1-9])$/).optional(), +}); + +const eventIdSchema = z.object({ + eventId: z.string().uuid(), +}); + +// Všetky routes vyžadujú autentifikáciu +router.use(authenticate); + +/** + * GET /api/events - Získať eventy a todos podľa mesiaca (filtrované podľa assigned users) + */ +router.get( + '/', + validateQuery(monthQuerySchema), + eventController.getCalendarData +); + +/** + * GET /api/events/:eventId - Získať konkrétny event + */ +router.get( + '/:eventId', + validateParams(eventIdSchema), + eventController.getEventById +); + +/** + * POST /api/events - Vytvoriť event (iba admin) + */ +router.post( + '/', + requireAdmin, + validateBody(createEventSchema), + eventController.createEvent +); + +/** + * PUT /api/events/:eventId - Upraviť event (iba admin) + */ +router.put( + '/:eventId', + requireAdmin, + validateParams(eventIdSchema), + validateBody(updateEventSchema), + eventController.updateEvent +); + +/** + * DELETE /api/events/:eventId - Zmazať event (iba admin) + */ +router.delete( + '/:eventId', + requireAdmin, + validateParams(eventIdSchema), + eventController.deleteEvent +); + +export default router; diff --git a/src/routes/meeting.routes.js b/src/routes/meeting.routes.js deleted file mode 100644 index 937b498..0000000 --- a/src/routes/meeting.routes.js +++ /dev/null @@ -1,90 +0,0 @@ -import express from 'express'; -import * as meetingController from '../controllers/meeting.controller.js'; -import { authenticate } from '../middlewares/auth/authMiddleware.js'; -import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; -import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js'; -import { z } from 'zod'; - -const router = express.Router(); - -// Schema pre meeting -const meetingSchema = z.object({ - title: z.string().min(1, 'Názov je povinný'), - description: z.string().optional(), - start: z.string().min(1, 'Začiatok je povinný'), - end: z.string().min(1, 'Koniec je povinný'), -}); - -const meetingUpdateSchema = z.object({ - title: z.string().min(1).optional(), - description: z.string().optional(), - start: z.string().optional(), - end: z.string().optional(), -}); - -const monthQuerySchema = z.object({ - year: z.string().regex(/^\d{4}$/).optional(), - month: z.string().regex(/^(1[0-2]|[1-9])$/).optional(), -}); - -const meetingIdSchema = z.object({ - meetingId: z.string().uuid(), -}); - -// Všetky routes vyžadujú autentifikáciu -router.use(authenticate); - -/** - * GET /api/meetings - Získať meetingy podľa mesiaca (všetci autentifikovaní používatelia) - */ -router.get( - '/', - validateQuery(monthQuerySchema), - meetingController.getMeetingsByMonth -); - -/** - * GET /api/meetings/:meetingId - Získať konkrétny meeting (všetci autentifikovaní používatelia) - */ -router.get( - '/:meetingId', - validateParams(meetingIdSchema), - meetingController.getMeetingById -); - -/** - * Admin-only routes (CREATE, UPDATE, DELETE) - */ - -/** - * POST /api/meetings - Vytvoriť meeting (iba admin) - */ -router.post( - '/', - requireAdmin, - validateBody(meetingSchema), - meetingController.createMeeting -); - -/** - * PUT /api/meetings/:meetingId - Upraviť meeting (iba admin) - */ -router.put( - '/:meetingId', - requireAdmin, - validateParams(meetingIdSchema), - validateBody(meetingUpdateSchema), - meetingController.updateMeeting -); - -/** - * DELETE /api/meetings/:meetingId - Zmazať meeting (iba admin) - */ -router.delete( - '/:meetingId', - requireAdmin, - validateParams(meetingIdSchema), - meetingController.deleteMeeting -); - -export default router; diff --git a/src/services/event.service.js b/src/services/event.service.js new file mode 100644 index 0000000..6ef62bb --- /dev/null +++ b/src/services/event.service.js @@ -0,0 +1,354 @@ +import { db } from '../config/database.js'; +import { events, eventUsers, users, todos, todoUsers } from '../db/schema.js'; +import { eq, and, gte, lt, desc, inArray } from 'drizzle-orm'; +import { NotFoundError } from '../utils/errors.js'; + +/** + * Parse datetime-local string to Date object + * datetime-local format: "2024-01-15T12:00" (no timezone) + * Interprets as Europe/Bratislava timezone + */ +const parseLocalDateTime = (dateTimeString) => { + if (!dateTimeString) return null; + + // Ak už má timezone info, parsuj priamo + if (dateTimeString.includes('Z') || /[+-]\d{2}:\d{2}$/.test(dateTimeString)) { + return new Date(dateTimeString); + } + + // Pre datetime-local input (bez timezone) - interpretuj ako Europe/Bratislava + let normalized = dateTimeString; + if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(dateTimeString)) { + normalized = `${dateTimeString}:00`; + } + + const [datePart] = normalized.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + + // Detekcia letného času pre Európu + const isDST = month > 3 && month < 10 || + (month === 3 && day >= 25) || + (month === 10 && day < 25); + + const offset = isDST ? '+02:00' : '+01:00'; + const isoString = `${normalized}${offset}`; + + return new Date(isoString); +}; + +/** + * Format event timestamps to ISO string with timezone + */ +const formatEventOutput = (event, assignedUsers = []) => { + if (!event) return null; + + return { + ...event, + start: event.start instanceof Date ? event.start.toISOString() : event.start, + end: event.end instanceof Date ? event.end.toISOString() : event.end, + createdAt: event.createdAt instanceof Date ? event.createdAt.toISOString() : event.createdAt, + updatedAt: event.updatedAt instanceof Date ? event.updatedAt.toISOString() : event.updatedAt, + assignedUsers, + }; +}; + +/** + * Get assigned users for events + */ +const getAssignedUsersForEvents = async (eventIds) => { + if (!eventIds || eventIds.length === 0) return {}; + + const assignedUsersData = await db + .select({ + eventId: eventUsers.eventId, + userId: users.id, + username: users.username, + firstName: users.firstName, + lastName: users.lastName, + }) + .from(eventUsers) + .innerJoin(users, eq(eventUsers.userId, users.id)) + .where(inArray(eventUsers.eventId, eventIds)); + + // Group by eventId + const usersByEventId = {}; + for (const row of assignedUsersData) { + if (!usersByEventId[row.eventId]) { + usersByEventId[row.eventId] = []; + } + usersByEventId[row.eventId].push({ + id: row.userId, + username: row.username, + firstName: row.firstName, + lastName: row.lastName, + }); + } + + return usersByEventId; +}; + +/** + * Get event IDs accessible to user (member sees only assigned events) + */ +const getAccessibleEventIds = async (userId) => { + const userEvents = await db + .select({ eventId: eventUsers.eventId }) + .from(eventUsers) + .where(eq(eventUsers.userId, userId)); + + return userEvents.map((row) => row.eventId); +}; + +/** + * Get calendar data - events and todos for month + * Filtered by user access (admin sees all, member sees only assigned) + */ +export const getCalendarData = async (year, month, userId, isAdmin) => { + // Month boundaries + const startOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01T00:00`; + const endMonth = month === 12 ? 1 : month + 1; + const endYear = month === 12 ? year + 1 : year; + const endOfMonthStr = `${endYear}-${String(endMonth).padStart(2, '0')}-01T00:00`; + + const startOfMonth = parseLocalDateTime(startOfMonthStr); + const endOfMonth = parseLocalDateTime(endOfMonthStr); + + // Get events + let monthEvents; + if (isAdmin) { + // Admin sees all events + monthEvents = await db + .select() + .from(events) + .where( + and( + gte(events.start, startOfMonth), + lt(events.start, endOfMonth) + ) + ) + .orderBy(desc(events.start)); + } else { + // Member sees only assigned events + const accessibleEventIds = await getAccessibleEventIds(userId); + if (accessibleEventIds.length === 0) { + monthEvents = []; + } else { + monthEvents = await db + .select() + .from(events) + .where( + and( + gte(events.start, startOfMonth), + lt(events.start, endOfMonth), + inArray(events.id, accessibleEventIds) + ) + ) + .orderBy(desc(events.start)); + } + } + + // Get assigned users for events + const eventIds = monthEvents.map((e) => e.id); + const usersByEventId = await getAssignedUsersForEvents(eventIds); + + const formattedEvents = monthEvents.map((event) => + formatEventOutput(event, usersByEventId[event.id] || []) + ); + + // Get todos for month (with dueDate in this month) + let monthTodos; + if (isAdmin) { + monthTodos = await db + .select() + .from(todos) + .where( + and( + gte(todos.dueDate, startOfMonth), + lt(todos.dueDate, endOfMonth) + ) + ) + .orderBy(desc(todos.dueDate)); + } else { + // Member sees only assigned todos + const userTodos = await db + .select({ todoId: todoUsers.todoId }) + .from(todoUsers) + .where(eq(todoUsers.userId, userId)); + + const todoIds = userTodos.map((row) => row.todoId); + + if (todoIds.length === 0) { + monthTodos = []; + } else { + monthTodos = await db + .select() + .from(todos) + .where( + and( + gte(todos.dueDate, startOfMonth), + lt(todos.dueDate, endOfMonth), + inArray(todos.id, todoIds) + ) + ) + .orderBy(desc(todos.dueDate)); + } + } + + // Format todos for calendar + const formattedTodos = monthTodos.map((todo) => ({ + ...todo, + dueDate: todo.dueDate instanceof Date ? todo.dueDate.toISOString() : todo.dueDate, + createdAt: todo.createdAt instanceof Date ? todo.createdAt.toISOString() : todo.createdAt, + updatedAt: todo.updatedAt instanceof Date ? todo.updatedAt.toISOString() : todo.updatedAt, + })); + + return { + events: formattedEvents, + todos: formattedTodos, + }; +}; + +/** + * Get event by ID + */ +export const getEventById = async (eventId, userId, isAdmin) => { + const [event] = await db + .select() + .from(events) + .where(eq(events.id, eventId)) + .limit(1); + + if (!event) { + throw new NotFoundError('Event nenájdený'); + } + + // Check access for non-admin + if (!isAdmin) { + const accessibleEventIds = await getAccessibleEventIds(userId); + if (!accessibleEventIds.includes(eventId)) { + throw new NotFoundError('Event nenájdený'); + } + } + + const usersByEventId = await getAssignedUsersForEvents([eventId]); + return formatEventOutput(event, usersByEventId[eventId] || []); +}; + +/** + * Create a new event + */ +export const createEvent = async (userId, data) => { + const { title, description, type, start, end, assignedUserIds } = data; + + // Create event + const [newEvent] = await db + .insert(events) + .values({ + title, + description: description || null, + type: type || 'meeting', + start: parseLocalDateTime(start), + end: parseLocalDateTime(end), + createdBy: userId, + }) + .returning(); + + // Always assign creator + any additional users + const allUserIds = new Set([userId]); + if (assignedUserIds && Array.isArray(assignedUserIds)) { + assignedUserIds.forEach((id) => allUserIds.add(id)); + } + + // Insert event users + const eventUserInserts = Array.from(allUserIds).map((uid) => ({ + eventId: newEvent.id, + userId: uid, + })); + + await db.insert(eventUsers).values(eventUserInserts); + + // Get assigned users for response + const usersByEventId = await getAssignedUsersForEvents([newEvent.id]); + return formatEventOutput(newEvent, usersByEventId[newEvent.id] || []); +}; + +/** + * Update an event + */ +export const updateEvent = async (eventId, data) => { + // Check if event exists + const [existingEvent] = await db + .select() + .from(events) + .where(eq(events.id, eventId)) + .limit(1); + + if (!existingEvent) { + throw new NotFoundError('Event nenájdený'); + } + + const { title, description, type, start, end, assignedUserIds } = data; + + const updateData = { + updatedAt: new Date(), + }; + + if (title !== undefined) updateData.title = title; + if (description !== undefined) updateData.description = description; + if (type !== undefined) updateData.type = type; + if (start !== undefined) updateData.start = parseLocalDateTime(start); + if (end !== undefined) updateData.end = parseLocalDateTime(end); + + const [updated] = await db + .update(events) + .set(updateData) + .where(eq(events.id, eventId)) + .returning(); + + // Update assigned users if provided + if (assignedUserIds !== undefined) { + // Delete existing assignments + await db.delete(eventUsers).where(eq(eventUsers.eventId, eventId)); + + // Always include creator + const allUserIds = new Set([existingEvent.createdBy]); + if (Array.isArray(assignedUserIds)) { + assignedUserIds.forEach((id) => allUserIds.add(id)); + } + + // Insert new assignments + const eventUserInserts = Array.from(allUserIds) + .filter((id) => id) // filter out null + .map((uid) => ({ + eventId: eventId, + userId: uid, + })); + + if (eventUserInserts.length > 0) { + await db.insert(eventUsers).values(eventUserInserts); + } + } + + const usersByEventId = await getAssignedUsersForEvents([eventId]); + return formatEventOutput(updated, usersByEventId[eventId] || []); +}; + +/** + * Delete an event + */ +export const deleteEvent = async (eventId) => { + const [event] = await db + .select() + .from(events) + .where(eq(events.id, eventId)) + .limit(1); + + if (!event) { + throw new NotFoundError('Event nenájdený'); + } + + // eventUsers will be deleted automatically due to CASCADE + await db.delete(events).where(eq(events.id, eventId)); + + return { success: true, message: 'Event bol zmazaný' }; +}; diff --git a/src/services/meeting.service.js b/src/services/meeting.service.js deleted file mode 100644 index b4c23d9..0000000 --- a/src/services/meeting.service.js +++ /dev/null @@ -1,163 +0,0 @@ -import { db } from '../config/database.js'; -import { meetings } from '../db/schema.js'; -import { eq, and, gte, lt, desc } from 'drizzle-orm'; -import { NotFoundError } from '../utils/errors.js'; - -/** - * Parse datetime-local string to Date object - * datetime-local format: "2024-01-15T12:00" (no timezone) - * Interprets as Europe/Bratislava timezone - */ -const parseLocalDateTime = (dateTimeString) => { - if (!dateTimeString) return null; - - // Ak už má timezone info, parsuj priamo - if (dateTimeString.includes('Z') || /[+-]\d{2}:\d{2}$/.test(dateTimeString)) { - return new Date(dateTimeString); - } - - // Pre datetime-local input (bez timezone) - interpretuj ako Europe/Bratislava - // Normalizuj string - pridaj sekundy ak chýbajú - let normalized = dateTimeString; - if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(dateTimeString)) { - normalized = `${dateTimeString}:00`; - } - - // Zisti či je letný alebo zimný čas pre daný dátum - // Parsuj dátum a mesiac zo stringu - const [datePart] = normalized.split('T'); - const [year, month, day] = datePart.split('-').map(Number); - - // Jednoduchá detekcia letného času pre Európu (posledná nedeľa marca - posledná nedeľa októbra) - const isDST = month > 3 && month < 10 || - (month === 3 && day >= 25) || - (month === 10 && day < 25); - - const offset = isDST ? '+02:00' : '+01:00'; - - // Vytvor ISO string s timezone - const isoString = `${normalized}${offset}`; - - return new Date(isoString); -}; - -/** - * Format meeting timestamps to ISO string with timezone - * This ensures frontend receives properly formatted dates - */ -const formatMeetingOutput = (meeting) => { - if (!meeting) return null; - - return { - ...meeting, - start: meeting.start instanceof Date ? meeting.start.toISOString() : meeting.start, - end: meeting.end instanceof Date ? meeting.end.toISOString() : meeting.end, - createdAt: meeting.createdAt instanceof Date ? meeting.createdAt.toISOString() : meeting.createdAt, - updatedAt: meeting.updatedAt instanceof Date ? meeting.updatedAt.toISOString() : meeting.updatedAt, - }; -}; - -/** - * Get meetings by month - * @param {number} year - rok - * @param {number} month - mesiac (1-12) - */ -export const getMeetingsByMonth = async (year, month) => { - // Začiatok a koniec mesiaca v Europe/Bratislava timezone - const startOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01T00:00`; - const endMonth = month === 12 ? 1 : month + 1; - const endYear = month === 12 ? year + 1 : year; - const endOfMonthStr = `${endYear}-${String(endMonth).padStart(2, '0')}-01T00:00`; - - const startOfMonth = parseLocalDateTime(startOfMonthStr); - const endOfMonth = parseLocalDateTime(endOfMonthStr); - - const monthMeetings = await db - .select() - .from(meetings) - .where( - and( - gte(meetings.start, startOfMonth), - lt(meetings.start, endOfMonth) - ) - ) - .orderBy(desc(meetings.start)); - - return monthMeetings.map(formatMeetingOutput); -}; - -/** - * Get meeting by ID - */ -export const getMeetingById = async (meetingId) => { - const [meeting] = await db - .select() - .from(meetings) - .where(eq(meetings.id, meetingId)) - .limit(1); - - if (!meeting) { - throw new NotFoundError('Meeting nenájdený'); - } - - return formatMeetingOutput(meeting); -}; - -/** - * Create a new meeting - */ -export const createMeeting = async (userId, data) => { - const { title, description, start, end } = data; - - const [newMeeting] = await db - .insert(meetings) - .values({ - title, - description: description || null, - start: parseLocalDateTime(start), - end: parseLocalDateTime(end), - createdBy: userId, - }) - .returning(); - - return formatMeetingOutput(newMeeting); -}; - -/** - * Update a meeting - */ -export const updateMeeting = async (meetingId, data) => { - // Check if meeting exists - await getMeetingById(meetingId); - - const { title, description, start, end } = data; - - const updateData = { - updatedAt: new Date(), - }; - - if (title !== undefined) updateData.title = title; - if (description !== undefined) updateData.description = description; - if (start !== undefined) updateData.start = parseLocalDateTime(start); - if (end !== undefined) updateData.end = parseLocalDateTime(end); - - const [updated] = await db - .update(meetings) - .set(updateData) - .where(eq(meetings.id, meetingId)) - .returning(); - - return formatMeetingOutput(updated); -}; - -/** - * Delete a meeting - */ -export const deleteMeeting = async (meetingId) => { - // Check if meeting exists - await getMeetingById(meetingId); - - await db.delete(meetings).where(eq(meetings.id, meetingId)); - - return { success: true, message: 'Meeting bol zmazaný' }; -}; diff --git a/src/validators/crm.validators.js b/src/validators/crm.validators.js index 9d410f7..1bda5b8 100644 --- a/src/validators/crm.validators.js +++ b/src/validators/crm.validators.js @@ -171,3 +171,22 @@ export const updateTimeEntrySchema = z.object({ companyId: optionalUuid('Neplatný formát company ID'), description: optionalDescription, }); + +// Event validators +export const createEventSchema = z.object({ + title: z.string().min(1, 'Názov je povinný'), + description: z.string().optional(), + type: z.enum(['meeting', 'event']).default('meeting'), + start: z.string().min(1, 'Začiatok je povinný'), + end: z.string().min(1, 'Koniec je povinný'), + assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), +}); + +export const updateEventSchema = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional(), + type: z.enum(['meeting', 'event']).optional(), + start: z.string().optional(), + end: z.string().optional(), + assignedUserIds: z.array(z.string().uuid('Neplatný formát user ID')).optional(), +});