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 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-28 17:23:57 +01:00
parent c3c42ec1e4
commit a4a81ef88e
16 changed files with 246 additions and 36 deletions

View File

@@ -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);
}
};

View File

@@ -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) => ({

View File

@@ -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');

View File

@@ -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 ====================

View File

@@ -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)
*/

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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'];

View File

@@ -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`,
})

View File

@@ -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`
)
);
}

View File

@@ -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,
};
};

View File

@@ -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))

View File

@@ -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;

View File

@@ -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',
}),