From eb5582feb69f37f625f650afb2f90f010ea6060c Mon Sep 17 00:00:00 2001 From: richardtekula Date: Fri, 5 Dec 2025 08:17:23 +0100 Subject: [PATCH] Add meetings feature with admin-only CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add meetings table with timezone support - Add meeting.service.js with timezone parsing (Europe/Bratislava) - Add meeting.controller.js for CRUD operations - Add meeting.routes.js with admin middleware for create/update/delete - GET endpoints available for all authenticated users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app.js | 2 + src/controllers/meeting.controller.js | 105 +++++++++++++++++ src/db/schema.js | 12 ++ src/routes/meeting.routes.js | 90 ++++++++++++++ src/services/meeting.service.js | 163 ++++++++++++++++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 src/controllers/meeting.controller.js create mode 100644 src/routes/meeting.routes.js create mode 100644 src/services/meeting.service.js diff --git a/src/app.js b/src/app.js index 0aa3e2e..6fb897a 100644 --- a/src/app.js +++ b/src/app.js @@ -24,6 +24,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'; const app = express(); @@ -86,6 +87,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); // Basic route app.get('/', (req, res) => { diff --git a/src/controllers/meeting.controller.js b/src/controllers/meeting.controller.js new file mode 100644 index 0000000..47b7b01 --- /dev/null +++ b/src/controllers/meeting.controller.js @@ -0,0 +1,105 @@ +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) { + return 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) { + return 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) { + return 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) { + return 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) { + return next(error); + } +}; diff --git a/src/db/schema.js b/src/db/schema.js index 2bc64cf..7dd67ce 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -213,6 +213,18 @@ 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', { + id: uuid('id').primaryKey().defaultRandom(), + title: text('title').notNull(), + description: text('description'), + start: timestamp('start', { withTimezone: true }).notNull(), + end: timestamp('end', { withTimezone: true }).notNull(), + createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}); + // 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/meeting.routes.js b/src/routes/meeting.routes.js new file mode 100644 index 0000000..937b498 --- /dev/null +++ b/src/routes/meeting.routes.js @@ -0,0 +1,90 @@ +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/meeting.service.js b/src/services/meeting.service.js new file mode 100644 index 0000000..b4c23d9 --- /dev/null +++ b/src/services/meeting.service.js @@ -0,0 +1,163 @@ +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ý' }; +};