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:
richardtekula
2025-12-15 10:50:31 +01:00
parent f828af562d
commit 3eb2f6ea02
10 changed files with 589 additions and 362 deletions

View 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ý' };
};