- Add team_leader role with appropriate permissions - Add lastSeen timestamp for chat online indicator - Add needsFollowup flag to ucastnici table - Add getTodayCalendarCount endpoint for calendar badge - Add company reminders to calendar data - Enhance company search to include phone and contacts - Update routes to allow team_leader access to kurzy, services, timesheets Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
import { db } from '../config/database.js';
|
|
import { events, eventUsers, users, todos, todoUsers, companyReminders, companyUsers, companies } from '../db/schema.js';
|
|
import { eq, and, gte, lt, desc, inArray, sql, ne } 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,
|
|
}));
|
|
|
|
// Get company reminders for month
|
|
let accessibleCompanyIds = null;
|
|
if (!isAdmin) {
|
|
// Member sees only reminders from companies they are assigned to
|
|
const userCompanies = await db
|
|
.select({ companyId: companyUsers.companyId })
|
|
.from(companyUsers)
|
|
.where(eq(companyUsers.userId, userId));
|
|
accessibleCompanyIds = userCompanies.map((row) => row.companyId);
|
|
}
|
|
|
|
let monthReminders = [];
|
|
if (isAdmin || (accessibleCompanyIds && accessibleCompanyIds.length > 0)) {
|
|
const reminderConditions = [
|
|
gte(companyReminders.dueDate, startOfMonth),
|
|
lt(companyReminders.dueDate, endOfMonth),
|
|
eq(companyReminders.isChecked, false),
|
|
];
|
|
|
|
if (!isAdmin && accessibleCompanyIds) {
|
|
reminderConditions.push(inArray(companyReminders.companyId, accessibleCompanyIds));
|
|
}
|
|
|
|
monthReminders = await db
|
|
.select({
|
|
id: companyReminders.id,
|
|
companyId: companyReminders.companyId,
|
|
description: companyReminders.description,
|
|
dueDate: companyReminders.dueDate,
|
|
isChecked: companyReminders.isChecked,
|
|
createdAt: companyReminders.createdAt,
|
|
companyName: companies.name,
|
|
})
|
|
.from(companyReminders)
|
|
.innerJoin(companies, eq(companyReminders.companyId, companies.id))
|
|
.where(and(...reminderConditions))
|
|
.orderBy(desc(companyReminders.dueDate));
|
|
}
|
|
|
|
// Format reminders for calendar
|
|
const formattedReminders = monthReminders.map((reminder) => ({
|
|
...reminder,
|
|
dueDate: reminder.dueDate instanceof Date ? reminder.dueDate.toISOString() : reminder.dueDate,
|
|
createdAt: reminder.createdAt instanceof Date ? reminder.createdAt.toISOString() : reminder.createdAt,
|
|
}));
|
|
|
|
return {
|
|
events: formattedEvents,
|
|
todos: formattedTodos,
|
|
reminders: formattedReminders,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 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ý' };
|
|
};
|
|
|
|
/**
|
|
* Get count of events and todos for today
|
|
* Used for calendar badge in sidebar
|
|
*/
|
|
export const getTodayCalendarCount = async (userId, isAdmin) => {
|
|
const today = new Date();
|
|
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
|
|
|
// Get event count for today
|
|
let eventCount;
|
|
if (isAdmin) {
|
|
const eventResult = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(events)
|
|
.where(
|
|
and(
|
|
gte(events.start, startOfDay),
|
|
lt(events.start, endOfDay)
|
|
)
|
|
);
|
|
eventCount = eventResult[0]?.count || 0;
|
|
} else {
|
|
const accessibleEventIds = await getAccessibleEventIds(userId);
|
|
if (accessibleEventIds.length === 0) {
|
|
eventCount = 0;
|
|
} else {
|
|
const eventResult = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(events)
|
|
.where(
|
|
and(
|
|
gte(events.start, startOfDay),
|
|
lt(events.start, endOfDay),
|
|
inArray(events.id, accessibleEventIds)
|
|
)
|
|
);
|
|
eventCount = eventResult[0]?.count || 0;
|
|
}
|
|
}
|
|
|
|
// Get todo count for today
|
|
let todoCount;
|
|
if (isAdmin) {
|
|
const todoResult = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(todos)
|
|
.where(
|
|
and(
|
|
gte(todos.dueDate, startOfDay),
|
|
lt(todos.dueDate, endOfDay),
|
|
ne(todos.status, 'completed')
|
|
)
|
|
);
|
|
todoCount = todoResult[0]?.count || 0;
|
|
} else {
|
|
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) {
|
|
todoCount = 0;
|
|
} else {
|
|
const todoResult = await db
|
|
.select({ count: sql`count(*)::int` })
|
|
.from(todos)
|
|
.where(
|
|
and(
|
|
gte(todos.dueDate, startOfDay),
|
|
lt(todos.dueDate, endOfDay),
|
|
ne(todos.status, 'completed'),
|
|
inArray(todos.id, todoIds)
|
|
)
|
|
);
|
|
todoCount = todoResult[0]?.count || 0;
|
|
}
|
|
}
|
|
|
|
return {
|
|
eventCount,
|
|
todoCount,
|
|
totalCount: eventCount + todoCount,
|
|
};
|
|
};
|