From a4a81ef88e1d5aa1a62ab006f25a256a4d848366 Mon Sep 17 00:00:00 2001 From: richardtekula Date: Wed, 28 Jan 2026 17:23:57 +0100 Subject: [PATCH] feat: Multi-feature CRM update - 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 --- src/controllers/event.controller.js | 20 +++ src/db/schema.js | 4 +- src/middlewares/auth/roleMiddleware.js | 4 + src/routes/ai-kurzy.routes.js | 6 +- src/routes/event.routes.js | 5 + src/routes/service.routes.js | 30 ++-- src/routes/time-tracking.routes.js | 6 +- src/routes/timesheet.routes.js | 4 +- src/routes/user.routes.js | 21 +++ src/services/ai-kurzy/registracie.service.js | 3 +- src/services/ai-kurzy/ucastnici.service.js | 1 + src/services/company.service.js | 26 +++- src/services/event.service.js | 139 ++++++++++++++++++- src/services/message.service.js | 1 + src/services/todo.service.js | 8 +- src/validators/auth.validators.js | 4 +- 16 files changed, 246 insertions(+), 36 deletions(-) diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js index 7bb4701..cdad2b8 100644 --- a/src/controllers/event.controller.js +++ b/src/controllers/event.controller.js @@ -128,3 +128,23 @@ export const sendEventNotification = async (req, res, next) => { next(error); } }; + +/** + * Get count of events and todos for today (for calendar badge) + * GET /api/events/today-count + */ +export const getTodayCount = async (req, res, next) => { + try { + const userId = req.userId; + const isAdmin = req.user?.role === 'admin' || req.user?.role === 'team_leader'; + + const counts = await eventService.getTodayCalendarCount(userId, isAdmin); + + res.status(200).json({ + success: true, + data: counts, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/db/schema.js b/src/db/schema.js index aa11841..b6bbd53 100644 --- a/src/db/schema.js +++ b/src/db/schema.js @@ -1,7 +1,7 @@ import { pgTable, text, timestamp, boolean, uuid, pgEnum, unique, integer, serial, varchar, numeric, date, bigint, uniqueIndex } from 'drizzle-orm/pg-core'; // Enums -export const roleEnum = pgEnum('role', ['admin', 'member']); +export const roleEnum = pgEnum('role', ['admin', 'team_leader', 'member']); export const projectStatusEnum = pgEnum('project_status', ['active', 'completed', 'on_hold', 'cancelled']); export const todoStatusEnum = pgEnum('todo_status', ['pending', 'in_progress', 'completed', 'cancelled']); export const todoPriorityEnum = pgEnum('todo_priority', ['low', 'medium', 'high', 'urgent']); @@ -18,6 +18,7 @@ export const users = pgTable('users', { changedPassword: boolean('changed_password').default(false), role: roleEnum('role').default('member').notNull(), lastLogin: timestamp('last_login'), + lastSeen: timestamp('last_seen'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); @@ -460,6 +461,7 @@ export const ucastnici = pgTable('ucastnici', { mesto: varchar('mesto', { length: 100 }), ulica: varchar('ulica', { length: 255 }), psc: varchar('psc', { length: 10 }), + needsFollowup: boolean('needs_followup').default(false).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ diff --git a/src/middlewares/auth/roleMiddleware.js b/src/middlewares/auth/roleMiddleware.js index 8583a2a..0e01fe9 100644 --- a/src/middlewares/auth/roleMiddleware.js +++ b/src/middlewares/auth/roleMiddleware.js @@ -38,3 +38,7 @@ export const requireRole = (...allowedRoles) => { */ export const requireAdmin = requireRole('admin'); +/** + * Middleware pre admin alebo team_leader rolu + */ +export const requireTeamLeaderOrAdmin = requireRole('admin', 'team_leader'); diff --git a/src/routes/ai-kurzy.routes.js b/src/routes/ai-kurzy.routes.js index 389e15c..90423e9 100644 --- a/src/routes/ai-kurzy.routes.js +++ b/src/routes/ai-kurzy.routes.js @@ -2,7 +2,7 @@ import express from 'express'; import path from 'path'; import * as aiKurzyController from '../controllers/ai-kurzy.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; -import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { requireTeamLeaderOrAdmin } from '../middlewares/auth/roleMiddleware.js'; import { validateBody, validateParams, validateQuery } from '../middlewares/security/validateInput.js'; import { createUpload, ALLOWED_FILE_TYPES } from '../config/upload.js'; import { @@ -29,9 +29,9 @@ const upload = createUpload({ diskPath: path.join(process.cwd(), 'uploads', 'ai-kurzy'), }); -// All routes require authentication and admin role +// All routes require authentication and admin or team_leader role router.use(authenticate); -router.use(requireAdmin); +router.use(requireTeamLeaderOrAdmin); // ==================== STATISTICS ==================== diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js index c7ed654..92f73fe 100644 --- a/src/routes/event.routes.js +++ b/src/routes/event.routes.js @@ -19,6 +19,11 @@ const eventIdSchema = z.object({ // Všetky routes vyžadujú autentifikáciu router.use(authenticate); +/** + * GET /api/events/today-count - Získať počet eventov a todos pre dnešok (pre badge v sidebar) + */ +router.get('/today-count', eventController.getTodayCount); + /** * GET /api/events - Získať eventy a todos podľa mesiaca (filtrované podľa assigned users) */ diff --git a/src/routes/service.routes.js b/src/routes/service.routes.js index 5fcdc9c..962a4e1 100644 --- a/src/routes/service.routes.js +++ b/src/routes/service.routes.js @@ -3,7 +3,7 @@ import * as serviceController from '../controllers/service.controller.js'; import * as serviceFolderController from '../controllers/service-folder.controller.js'; import * as serviceDocumentController from '../controllers/service-document.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; -import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { requireAdmin, requireTeamLeaderOrAdmin } from '../middlewares/auth/roleMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { createServiceSchema, updateServiceSchema } from '../validators/crm.validators.js'; import { createUpload } from '../config/upload.js'; @@ -30,11 +30,11 @@ router.use(authenticate); router.get('/folders', serviceFolderController.getAllFolders); /** - * POST /api/services/folders - Create new folder (admin only) + * POST /api/services/folders - Create new folder (admin/team_leader) */ router.post( '/folders', - requireAdmin, + requireTeamLeaderOrAdmin, validateBody(createFolderSchema), serviceFolderController.createFolder ); @@ -49,22 +49,22 @@ router.get( ); /** - * PUT /api/services/folders/:folderId - Update folder (admin only) + * PUT /api/services/folders/:folderId - Update folder (admin/team_leader) */ router.put( '/folders/:folderId', - requireAdmin, + requireTeamLeaderOrAdmin, validateParams(folderIdSchema), validateBody(updateFolderSchema), serviceFolderController.updateFolder ); /** - * DELETE /api/services/folders/:folderId - Delete folder (admin only) + * DELETE /api/services/folders/:folderId - Delete folder (admin/team_leader) */ router.delete( '/folders/:folderId', - requireAdmin, + requireTeamLeaderOrAdmin, validateParams(folderIdSchema), serviceFolderController.deleteFolder ); @@ -100,11 +100,11 @@ router.get( ); /** - * DELETE /api/services/folders/:folderId/documents/:documentId - Delete document (admin only) + * DELETE /api/services/folders/:folderId/documents/:documentId - Delete document (admin/team_leader) */ router.delete( '/folders/:folderId/documents/:documentId', - requireAdmin, + requireTeamLeaderOrAdmin, validateParams(folderDocumentIdSchema), serviceDocumentController.deleteDocument ); @@ -117,11 +117,11 @@ router.delete( router.get('/', serviceController.getAllServices); /** - * POST /api/services - Create new service (admin only) + * POST /api/services - Create new service (admin/team_leader) */ router.post( '/', - requireAdmin, + requireTeamLeaderOrAdmin, validateBody(createServiceSchema), serviceController.createService ); @@ -136,22 +136,22 @@ router.get( ); /** - * PUT /api/services/:serviceId - Update service (admin only) + * PUT /api/services/:serviceId - Update service (admin/team_leader) */ router.put( '/:serviceId', - requireAdmin, + requireTeamLeaderOrAdmin, validateParams(serviceIdSchema), validateBody(updateServiceSchema), serviceController.updateService ); /** - * DELETE /api/services/:serviceId - Delete service (admin only) + * DELETE /api/services/:serviceId - Delete service (admin/team_leader) */ router.delete( '/:serviceId', - requireAdmin, + requireTeamLeaderOrAdmin, validateParams(serviceIdSchema), serviceController.deleteService ); diff --git a/src/routes/time-tracking.routes.js b/src/routes/time-tracking.routes.js index a43d2e6..088838b 100644 --- a/src/routes/time-tracking.routes.js +++ b/src/routes/time-tracking.routes.js @@ -1,7 +1,7 @@ import express from 'express'; import * as timeTrackingController from '../controllers/time-tracking.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; -import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { requireTeamLeaderOrAdmin } from '../middlewares/auth/roleMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { startTimeEntrySchema, @@ -47,8 +47,8 @@ router.post( // Get running time entry router.get('/running', timeTrackingController.getRunningTimeEntry); -// Get all running time entries (for dashboard) - admin only -router.get('/running-all', requireAdmin, timeTrackingController.getAllRunningTimeEntries); +// Get all running time entries (for dashboard) - admin/team_leader +router.get('/running-all', requireTeamLeaderOrAdmin, timeTrackingController.getAllRunningTimeEntries); // Get all time entries with filters router.get('/', timeTrackingController.getAllTimeEntries); diff --git a/src/routes/timesheet.routes.js b/src/routes/timesheet.routes.js index d969700..82e8af1 100644 --- a/src/routes/timesheet.routes.js +++ b/src/routes/timesheet.routes.js @@ -1,7 +1,7 @@ import express from 'express'; import * as timesheetController from '../controllers/timesheet.controller.js'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; -import { requireAdmin } from '../middlewares/auth/roleMiddleware.js'; +import { requireTeamLeaderOrAdmin } from '../middlewares/auth/roleMiddleware.js'; import { validateBody, validateParams } from '../middlewares/security/validateInput.js'; import { z } from 'zod'; import { createUpload } from '../config/upload.js'; @@ -48,7 +48,7 @@ router.get('/my', timesheetController.getMyTimesheets); * Get all timesheets (admin only) * GET /api/timesheets/all */ -router.get('/all', requireAdmin, timesheetController.getAllTimesheets); +router.get('/all', requireTeamLeaderOrAdmin, timesheetController.getAllTimesheets); /** * Download timesheet diff --git a/src/routes/user.routes.js b/src/routes/user.routes.js index 7278b7c..a03590d 100644 --- a/src/routes/user.routes.js +++ b/src/routes/user.routes.js @@ -2,6 +2,7 @@ import express from 'express'; import { authenticate } from '../middlewares/auth/authMiddleware.js'; import { db } from '../config/database.js'; import { users } from '../db/schema.js'; +import { eq } from 'drizzle-orm'; const router = express.Router(); @@ -21,6 +22,7 @@ router.get('/', async (req, res, next) => { firstName: users.firstName, lastName: users.lastName, role: users.role, + lastSeen: users.lastSeen, }) .from(users) .orderBy(users.username); @@ -31,4 +33,23 @@ router.get('/', async (req, res, next) => { } }); +/** + * Update user's lastSeen timestamp (heartbeat) + * POST /api/users/heartbeat + */ +router.post('/heartbeat', async (req, res, next) => { + try { + const userId = req.userId; + + await db + .update(users) + .set({ lastSeen: new Date() }) + .where(eq(users.id, userId)); + + res.json({ success: true }); + } catch (error) { + next(error); + } +}); + export default router; diff --git a/src/services/ai-kurzy/registracie.service.js b/src/services/ai-kurzy/registracie.service.js index b19f357..446e4ad 100644 --- a/src/services/ai-kurzy/registracie.service.js +++ b/src/services/ai-kurzy/registracie.service.js @@ -130,6 +130,7 @@ export const getCombinedTableData = async () => { mesto: ucastnici.mesto, ulica: ucastnici.ulica, psc: ucastnici.psc, + needsFollowup: ucastnici.needsFollowup, kurzId: kurzy.id, kurzNazov: kurzy.nazov, kurzTyp: kurzy.typKurzu, @@ -155,7 +156,7 @@ export const getCombinedTableData = async () => { }; export const updateField = async (registrationId, field, value) => { - const ucastnikFields = ['titul', 'meno', 'priezvisko', 'email', 'telefon', 'firma', 'firmaIco', 'firmaDic', 'firmaIcDph', 'firmaSidlo', 'mesto', 'ulica', 'psc']; + const ucastnikFields = ['titul', 'meno', 'priezvisko', 'email', 'telefon', 'firma', 'firmaIco', 'firmaDic', 'firmaIcDph', 'firmaSidlo', 'mesto', 'ulica', 'psc', 'needsFollowup']; const registraciaFields = ['datumOd', 'datumDo', 'formaKurzu', 'pocetUcastnikov', 'fakturaCislo', 'fakturaVystavena', 'zaplatene', 'stav', 'poznamka', 'kurzId']; const dateFields = ['datumOd', 'datumDo']; diff --git a/src/services/ai-kurzy/ucastnici.service.js b/src/services/ai-kurzy/ucastnici.service.js index e386ab7..c3acd1c 100644 --- a/src/services/ai-kurzy/ucastnici.service.js +++ b/src/services/ai-kurzy/ucastnici.service.js @@ -20,6 +20,7 @@ export const getAllUcastnici = async () => { mesto: ucastnici.mesto, ulica: ucastnici.ulica, psc: ucastnici.psc, + needsFollowup: ucastnici.needsFollowup, createdAt: ucastnici.createdAt, registraciiCount: sql`(SELECT COUNT(*) FROM registracie WHERE ucastnik_id = ${ucastnici.id})::int`, }) diff --git a/src/services/company.service.js b/src/services/company.service.js index 16d48f0..6fb25df 100644 --- a/src/services/company.service.js +++ b/src/services/company.service.js @@ -1,6 +1,6 @@ import { db } from '../config/database.js'; -import { companies, projects, todos, notes, companyReminders, companyUsers, users } from '../db/schema.js'; -import { eq, desc, ilike, or, and, inArray } from 'drizzle-orm'; +import { companies, projects, todos, notes, companyReminders, companyUsers, users, personalContacts } from '../db/schema.js'; +import { eq, desc, ilike, or, and, inArray, sql } from 'drizzle-orm'; import { NotFoundError, ConflictError } from '../utils/errors.js'; import { getAccessibleResourceIds } from '../middlewares/auth/resourceAccessMiddleware.js'; import { @@ -57,11 +57,31 @@ export const getAllCompanies = async (searchTerm = null, userId = null, userRole } if (searchTerm) { + // Find company IDs that have matching contacts + const contactMatches = await db + .selectDistinct({ companyId: personalContacts.companyId }) + .from(personalContacts) + .where( + and( + sql`${personalContacts.companyId} IS NOT NULL`, + or( + ilike(personalContacts.firstName, `%${searchTerm}%`), + ilike(personalContacts.lastName, `%${searchTerm}%`), + ilike(personalContacts.email, `%${searchTerm}%`), + ilike(personalContacts.phone, `%${searchTerm}%`) + ) + ) + ); + + const contactCompanyIds = contactMatches.map((c) => c.companyId).filter(Boolean); + conditions.push( or( ilike(companies.name, `%${searchTerm}%`), ilike(companies.email, `%${searchTerm}%`), - ilike(companies.city, `%${searchTerm}%`) + ilike(companies.city, `%${searchTerm}%`), + ilike(companies.phone, `%${searchTerm}%`), + contactCompanyIds.length > 0 ? inArray(companies.id, contactCompanyIds) : sql`false` ) ); } diff --git a/src/services/event.service.js b/src/services/event.service.js index 6ef62bb..e51b68f 100644 --- a/src/services/event.service.js +++ b/src/services/event.service.js @@ -1,6 +1,6 @@ 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 { 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'; /** @@ -202,9 +202,56 @@ export const getCalendarData = async (year, month, userId, isAdmin) => { 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, }; }; @@ -352,3 +399,91 @@ export const deleteEvent = async (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, + }; +}; diff --git a/src/services/message.service.js b/src/services/message.service.js index 317cbb8..206d11a 100644 --- a/src/services/message.service.js +++ b/src/services/message.service.js @@ -297,6 +297,7 @@ export const getChatUsers = async (currentUserId) => { lastName: users.lastName, role: users.role, lastLogin: users.lastLogin, + lastSeen: users.lastSeen, }) .from(users) .where(ne(users.id, currentUserId)) diff --git a/src/services/todo.service.js b/src/services/todo.service.js index a9fbee9..a1c42cb 100644 --- a/src/services/todo.service.js +++ b/src/services/todo.service.js @@ -16,9 +16,9 @@ import { export const getAllTodos = async (filters = {}, userId = null, userRole = null) => { const { searchTerm, projectId, companyId, assignedTo, status, priority } = filters; - // Pre membera filtruj len todos kde je priradeny + // Pre membera filtruj len todos kde je priradeny (admin a team_leader vidia vsetko) let accessibleTodoIds = null; - if (userRole && userRole !== 'admin' && userId) { + if (userRole && userRole !== 'admin' && userRole !== 'team_leader' && userId) { accessibleTodoIds = await getAccessibleResourceIds('todo', userId); // Ak member nema pristup k ziadnym todos, vrat prazdne pole if (accessibleTodoIds.length === 0) { @@ -457,9 +457,9 @@ export const getTodosByUserId = async (userId) => { export const getOverdueCount = async (userId, userRole) => { const now = new Date(); - // Get accessible todo IDs for non-admin users + // Get accessible todo IDs for non-admin/non-team_leader users let accessibleTodoIds = null; - if (userRole && userRole !== 'admin') { + if (userRole && userRole !== 'admin' && userRole !== 'team_leader') { accessibleTodoIds = await getAccessibleResourceIds('todo', userId); if (accessibleTodoIds.length === 0) { return 0; diff --git a/src/validators/auth.validators.js b/src/validators/auth.validators.js index 5e3378e..6fe767f 100644 --- a/src/validators/auth.validators.js +++ b/src/validators/auth.validators.js @@ -76,7 +76,7 @@ export const createUserSchema = z.object({ emailPassword: z.string().min(1).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional(), - role: z.enum(['admin', 'member']).optional(), + role: z.enum(['admin', 'team_leader', 'member']).optional(), }); // Update user schema @@ -89,7 +89,7 @@ export const updateUserSchema = z.object({ // Change role schema (admin only) export const changeRoleSchema = z.object({ userId: z.string().uuid('Neplatný formát user ID'), - role: z.enum(['admin', 'member'], { + role: z.enum(['admin', 'team_leader', 'member'], { required_error: 'Rola je povinná', invalid_type_error: 'Neplatná rola', }),