feat: Replace Meetings with Calendar - events with types and assigned users
- Rename meetings table to events with type field (meeting/event) - Add eventUsers junction table for user assignments - Members see only events they're assigned to - Calendar endpoint returns events + todos for month - Add migration SQL for database changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,7 @@ import todoRoutes from './routes/todo.routes.js';
|
|||||||
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
import timeTrackingRoutes from './routes/time-tracking.routes.js';
|
||||||
import noteRoutes from './routes/note.routes.js';
|
import noteRoutes from './routes/note.routes.js';
|
||||||
import auditRoutes from './routes/audit.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();
|
const app = express();
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ app.use('/api/todos', todoRoutes);
|
|||||||
app.use('/api/time-tracking', timeTrackingRoutes);
|
app.use('/api/time-tracking', timeTrackingRoutes);
|
||||||
app.use('/api/notes', noteRoutes);
|
app.use('/api/notes', noteRoutes);
|
||||||
app.use('/api/audit-logs', auditRoutes);
|
app.use('/api/audit-logs', auditRoutes);
|
||||||
app.use('/api/meetings', meetingRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
|
|
||||||
// Basic route
|
// Basic route
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
|
|||||||
108
src/controllers/event.controller.js
Normal file
108
src/controllers/event.controller.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
21
src/db/migrations/0001_meetings_to_events.sql
Normal file
21
src/db/migrations/0001_meetings_to_events.sql
Normal file
@@ -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;
|
||||||
@@ -242,11 +242,12 @@ export const timesheets = pgTable('timesheets', {
|
|||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meetings table - meetingy/stretnutia (iba admin môže CRUD)
|
// Events table - udalosti v kalendári (meeting/event)
|
||||||
export const meetings = pgTable('meetings', {
|
export const events = pgTable('events', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
|
type: text('type').notNull().default('meeting'), // 'meeting' | 'event'
|
||||||
start: timestamp('start', { withTimezone: true }).notNull(),
|
start: timestamp('start', { withTimezone: true }).notNull(),
|
||||||
end: timestamp('end', { withTimezone: true }).notNull(),
|
end: timestamp('end', { withTimezone: true }).notNull(),
|
||||||
createdBy: uuid('created_by').references(() => users.id, { onDelete: 'set null' }),
|
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(),
|
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
|
// Time Entries table - sledovanie odpracovaného času používateľov
|
||||||
export const timeEntries = pgTable('time_entries', {
|
export const timeEntries = pgTable('time_entries', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|||||||
72
src/routes/event.routes.js
Normal file
72
src/routes/event.routes.js
Normal file
@@ -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;
|
||||||
@@ -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;
|
|
||||||
354
src/services/event.service.js
Normal file
354
src/services/event.service.js
Normal file
@@ -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ý' };
|
||||||
|
};
|
||||||
@@ -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ý' };
|
|
||||||
};
|
|
||||||
@@ -171,3 +171,22 @@ export const updateTimeEntrySchema = z.object({
|
|||||||
companyId: optionalUuid('Neplatný formát company ID'),
|
companyId: optionalUuid('Neplatný formát company ID'),
|
||||||
description: optionalDescription,
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user