Správa používateľov + notifikačný systém
- Pridaná kompletná správa používateľov (CRUD, reset hesla, zmena roly) pre ROOT/ADMIN - Backend: POST /users endpoint, createUser controller, validácia - Frontend: UserManagement, UserForm, PasswordResetModal komponenty - Settings prístupné pre ROOT aj ADMIN (AdminRoute) - Notifikačný systém s snooze funkcionalitou - Aktualizácia HELPDESK_INIT_V2.md dokumentácie Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ model User {
|
||||
taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader")
|
||||
|
||||
createdCustomers Customer[]
|
||||
notifications Notification[]
|
||||
|
||||
@@index([email])
|
||||
@@index([roleId])
|
||||
@@ -374,11 +375,12 @@ model Task {
|
||||
createdById String
|
||||
createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
|
||||
|
||||
assignees TaskAssignee[]
|
||||
reminders Reminder[]
|
||||
comments Comment[]
|
||||
tags TaskTag[]
|
||||
attachments TaskAttachment[]
|
||||
assignees TaskAssignee[]
|
||||
reminders Reminder[]
|
||||
comments Comment[]
|
||||
tags TaskTag[]
|
||||
attachments TaskAttachment[]
|
||||
notifications Notification[]
|
||||
|
||||
@@index([projectId])
|
||||
@@index([parentId])
|
||||
@@ -633,6 +635,7 @@ model RMA {
|
||||
statusHistory RMAStatusHistory[]
|
||||
comments RMAComment[]
|
||||
tags RMATag[]
|
||||
notifications Notification[]
|
||||
|
||||
@@index([rmaNumber])
|
||||
@@index([customerId])
|
||||
@@ -706,6 +709,40 @@ model RMATag {
|
||||
@@id([rmaId, tagId])
|
||||
}
|
||||
|
||||
// ==================== NOTIFICATIONS ====================
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
type String // TASK_ASSIGNED, TASK_STATUS_CHANGED, TASK_COMMENT, TASK_DEADLINE, RMA_ASSIGNED, etc.
|
||||
|
||||
taskId String?
|
||||
task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
|
||||
rmaId String?
|
||||
rma RMA? @relation(fields: [rmaId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
message String
|
||||
data Json? // Extra data (oldStatus, newStatus, etc.)
|
||||
|
||||
isRead Boolean @default(false)
|
||||
readAt DateTime?
|
||||
|
||||
snoozedUntil DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, isRead])
|
||||
@@index([taskId])
|
||||
@@index([rmaId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// ==================== ACTIVITY LOG ====================
|
||||
|
||||
model ActivityLog {
|
||||
|
||||
@@ -203,6 +203,19 @@ async function seed() {
|
||||
label: 'Zapnúť real-time aktualizácie (WebSocket)',
|
||||
dataType: 'boolean',
|
||||
},
|
||||
{
|
||||
key: 'NOTIFICATION_SNOOZE_OPTIONS',
|
||||
value: [
|
||||
{ label: '30 minút', minutes: 30 },
|
||||
{ label: '1 hodina', minutes: 60 },
|
||||
{ label: '3 hodiny', minutes: 180 },
|
||||
{ label: 'Zajtra ráno', type: 'tomorrow', hour: 9 },
|
||||
],
|
||||
category: 'NOTIFICATIONS',
|
||||
label: 'Možnosti odloženia notifikácií',
|
||||
description: 'Pole objektov. Každý má "label" a buď "minutes" (relatívny čas) alebo "type" + "hour" (konkrétny čas). Type: "today" (ak čas prešiel, skryje sa), "tomorrow".',
|
||||
dataType: 'json',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
100
backend/src/controllers/notification.controller.ts
Normal file
100
backend/src/controllers/notification.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { notificationService } from '../services/notification.service';
|
||||
import { successResponse, errorResponse, parseQueryInt, parseQueryBoolean, getParam } from '../utils/helpers';
|
||||
|
||||
// Get notifications for current user
|
||||
export const getNotifications = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const limit = parseQueryInt(req.query.limit, 50);
|
||||
const offset = parseQueryInt(req.query.offset, 0);
|
||||
const unreadOnly = parseQueryBoolean(req.query.unreadOnly, false);
|
||||
|
||||
const { notifications, total } = await notificationService.getForUser(userId, {
|
||||
limit,
|
||||
offset,
|
||||
unreadOnly,
|
||||
});
|
||||
|
||||
successResponse(res, { notifications, total, limit, offset });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error getting notifications:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní notifikácií', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unread count
|
||||
export const getUnreadCount = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const count = await notificationService.getUnreadCount(userId);
|
||||
successResponse(res, { count });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error getting unread count:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní počtu notifikácií', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark notification as read
|
||||
export const markAsRead = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const id = getParam(req, 'id');
|
||||
|
||||
await notificationService.markAsRead(id, userId);
|
||||
successResponse(res, { message: 'Notifikácia označená ako prečítaná' });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error marking as read:', error);
|
||||
errorResponse(res, 'Chyba pri označovaní notifikácie', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Mark all notifications as read
|
||||
export const markAllAsRead = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const result = await notificationService.markAllAsRead(userId);
|
||||
successResponse(res, { message: 'Všetky notifikácie označené ako prečítané', count: result.count });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error marking all as read:', error);
|
||||
errorResponse(res, 'Chyba pri označovaní notifikácií', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Snooze notification (remind later)
|
||||
export const snoozeNotification = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const id = getParam(req, 'id');
|
||||
const { minutes } = req.body;
|
||||
|
||||
if (!minutes || typeof minutes !== 'number' || minutes <= 0) {
|
||||
errorResponse(res, 'Neplatný čas odloženia', 400);
|
||||
return;
|
||||
}
|
||||
|
||||
const until = new Date();
|
||||
until.setMinutes(until.getMinutes() + minutes);
|
||||
|
||||
await notificationService.snooze(id, userId, until);
|
||||
successResponse(res, { message: 'Notifikácia odložená', snoozedUntil: until });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error snoozing:', error);
|
||||
errorResponse(res, 'Chyba pri odkladaní notifikácie', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete notification
|
||||
export const deleteNotification = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.user!.userId;
|
||||
const id = getParam(req, 'id');
|
||||
|
||||
await notificationService.delete(id, userId);
|
||||
successResponse(res, { message: 'Notifikácia vymazaná' });
|
||||
} catch (error) {
|
||||
console.error('[Notification] Error deleting:', error);
|
||||
errorResponse(res, 'Chyba pri mazaní notifikácie', 500);
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getPa
|
||||
import { AuthRequest } from '../middleware/auth.middleware';
|
||||
import { configService } from '../services/config.service';
|
||||
import { movePendingFilesToEntity } from './upload.controller';
|
||||
import { notificationService } from '../services/notification.service';
|
||||
|
||||
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -167,6 +168,13 @@ export const createTask = async (req: AuthRequest, res: Response): Promise<void>
|
||||
userId,
|
||||
})),
|
||||
});
|
||||
|
||||
// Notify assigned users
|
||||
await notificationService.notifyTaskAssignment(
|
||||
task.id,
|
||||
req.body.assigneeIds,
|
||||
req.user!.userId
|
||||
);
|
||||
}
|
||||
|
||||
// Move pending files if tempId provided
|
||||
@@ -189,13 +197,22 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise<void>
|
||||
try {
|
||||
const id = getParam(req, 'id');
|
||||
|
||||
const existing = await prisma.task.findUnique({ where: { id } });
|
||||
const existing = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
status: { select: { name: true } },
|
||||
assignees: { select: { userId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldStatusName = existing.status.name;
|
||||
const oldAssigneeIds = existing.assignees.map((a) => a.userId);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (req.body.title) updateData.title = req.body.title;
|
||||
if (req.body.description !== undefined) updateData.description = req.body.description;
|
||||
@@ -245,6 +262,26 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise<void>
|
||||
},
|
||||
});
|
||||
|
||||
// Notify about status change
|
||||
if (req.body.statusId && updatedTask && oldStatusName !== updatedTask.status.name) {
|
||||
await notificationService.notifyTaskStatusChange(
|
||||
id,
|
||||
oldStatusName,
|
||||
updatedTask.status.name,
|
||||
req.user!.userId
|
||||
);
|
||||
}
|
||||
|
||||
// Notify new assignees
|
||||
if (req.body.assigneeIds !== undefined) {
|
||||
const newAssigneeIds = (req.body.assigneeIds || []).filter(
|
||||
(userId: string) => !oldAssigneeIds.includes(userId)
|
||||
);
|
||||
if (newAssigneeIds.length > 0) {
|
||||
await notificationService.notifyTaskAssignment(id, newAssigneeIds, req.user!.userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('UPDATE', 'Task', id, updateData);
|
||||
}
|
||||
@@ -285,17 +322,40 @@ export const updateTaskStatus = async (req: AuthRequest, res: Response): Promise
|
||||
const id = getParam(req, 'id');
|
||||
const { statusId } = req.body;
|
||||
|
||||
const status = await prisma.taskStatus.findUnique({ where: { id: statusId } });
|
||||
// Get current task with old status
|
||||
const currentTask = await prisma.task.findUnique({
|
||||
where: { id },
|
||||
include: { status: true },
|
||||
});
|
||||
|
||||
if (!currentTask) {
|
||||
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const oldStatusName = currentTask.status.name;
|
||||
|
||||
const newStatus = await prisma.taskStatus.findUnique({ where: { id: statusId } });
|
||||
|
||||
const task = await prisma.task.update({
|
||||
where: { id },
|
||||
data: {
|
||||
statusId,
|
||||
completedAt: status?.isFinal ? new Date() : null,
|
||||
completedAt: newStatus?.isFinal ? new Date() : null,
|
||||
},
|
||||
include: { status: true },
|
||||
});
|
||||
|
||||
// Notify about status change
|
||||
if (oldStatusName !== task.status.name) {
|
||||
await notificationService.notifyTaskStatusChange(
|
||||
id,
|
||||
oldStatusName,
|
||||
task.status.name,
|
||||
req.user!.userId
|
||||
);
|
||||
}
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('STATUS_CHANGE', 'Task', id, { statusId });
|
||||
}
|
||||
@@ -398,6 +458,9 @@ export const addTaskComment = async (req: AuthRequest, res: Response): Promise<v
|
||||
},
|
||||
});
|
||||
|
||||
// Notify about new comment
|
||||
await notificationService.notifyTaskComment(id, comment.id, userId, comment.user.name);
|
||||
|
||||
successResponse(res, comment, 'Komentár bol pridaný.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error adding task comment:', error);
|
||||
|
||||
@@ -40,6 +40,54 @@ export const getUsersSimple = async (req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password, name, roleId } = req.body;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email } });
|
||||
if (existingUser) {
|
||||
errorResponse(res, 'Email je už používaný.', 409);
|
||||
return;
|
||||
}
|
||||
|
||||
const role = await prisma.userRole.findUnique({ where: { id: roleId } });
|
||||
if (!role || !role.active) {
|
||||
errorResponse(res, 'Rola neexistuje alebo nie je aktívna.', 404);
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: { email, password: hashedPassword, name, roleId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (req.logActivity) {
|
||||
await req.logActivity('CREATE', 'User', user.id, { email, name, roleId });
|
||||
}
|
||||
|
||||
successResponse(res, user, 'Používateľ bol vytvorený.', 201);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
errorResponse(res, 'Chyba pri vytváraní používateľa.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const getUsers = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
const page = parseQueryInt(req.query.page, 1);
|
||||
|
||||
@@ -20,6 +20,8 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise<void>
|
||||
const rok = parseQueryInt(req.query.rok, new Date().getFullYear());
|
||||
const search = getQueryString(req, 'search');
|
||||
|
||||
console.log(`[Zakazky] Fetching year=${rok}, search=${search || 'none'}`);
|
||||
|
||||
let zakazky;
|
||||
|
||||
if (search) {
|
||||
@@ -28,10 +30,16 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise<void>
|
||||
zakazky = await externalDbService.getZakazkyByYear(rok);
|
||||
}
|
||||
|
||||
console.log(`[Zakazky] Found ${zakazky.length} records`);
|
||||
successResponse(res, zakazky);
|
||||
} catch (error) {
|
||||
console.error('Error fetching zakazky:', error);
|
||||
errorResponse(res, 'Chyba pri načítaní zákaziek z externej databázy.', 500);
|
||||
const err = error as Error;
|
||||
console.error('[Zakazky] Error:', err.message, err.stack);
|
||||
// Return detailed error in development
|
||||
const message = process.env.NODE_ENV === 'development'
|
||||
? `Chyba: ${err.message}`
|
||||
: 'Chyba pri načítaní zákaziek z externej databázy.';
|
||||
errorResponse(res, message, 500);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import settingsRoutes from './settings.routes';
|
||||
import dashboardRoutes from './dashboard.routes';
|
||||
import uploadRoutes from './upload.routes';
|
||||
import zakazkyRoutes from './zakazky.routes';
|
||||
import notificationRoutes from './notification.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -24,5 +25,6 @@ router.use('/settings', settingsRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
router.use('/files', uploadRoutes);
|
||||
router.use('/zakazky', zakazkyRoutes);
|
||||
router.use('/notifications', notificationRoutes);
|
||||
|
||||
export default router;
|
||||
|
||||
35
backend/src/routes/notification.routes.ts
Normal file
35
backend/src/routes/notification.routes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
snoozeNotification,
|
||||
deleteNotification,
|
||||
} from '../controllers/notification.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/notifications - Get user's notifications
|
||||
router.get('/', getNotifications);
|
||||
|
||||
// GET /api/notifications/unread-count - Get unread count
|
||||
router.get('/unread-count', getUnreadCount);
|
||||
|
||||
// POST /api/notifications/mark-all-read - Mark all as read
|
||||
router.post('/mark-all-read', markAllAsRead);
|
||||
|
||||
// POST /api/notifications/:id/read - Mark single notification as read
|
||||
router.post('/:id/read', markAsRead);
|
||||
|
||||
// POST /api/notifications/:id/snooze - Snooze notification
|
||||
router.post('/:id/snooze', snoozeNotification);
|
||||
|
||||
// DELETE /api/notifications/:id - Delete notification
|
||||
router.delete('/:id', deleteNotification);
|
||||
|
||||
export default router;
|
||||
@@ -25,6 +25,10 @@ router.get('/rma-solutions', settingsController.getRMASolutions);
|
||||
router.get('/tags', settingsController.getTags);
|
||||
// User Roles - čítanie
|
||||
router.get('/roles', settingsController.getUserRoles);
|
||||
// System Settings - čítanie (pre všetkých prihlásených)
|
||||
router.get('/system', settingsController.getSystemSettings);
|
||||
router.get('/system/category/:category', settingsController.getSystemSettingsByCategory);
|
||||
router.get('/system/:key', settingsController.getSystemSetting);
|
||||
|
||||
// === ROOT-ONLY ENDPOINTY (vytvorenie, úprava, mazanie) ===
|
||||
router.use(isRoot);
|
||||
@@ -69,10 +73,7 @@ router.post('/roles', settingsController.createUserRole);
|
||||
router.put('/roles/:id', settingsController.updateUserRole);
|
||||
router.delete('/roles/:id', settingsController.deleteUserRole);
|
||||
|
||||
// System Settings - len ROOT
|
||||
router.get('/system', settingsController.getSystemSettings);
|
||||
router.get('/system/category/:category', settingsController.getSystemSettingsByCategory);
|
||||
router.get('/system/:key', settingsController.getSystemSetting);
|
||||
// System Settings - úprava (len ROOT)
|
||||
router.put('/system/:key', settingsController.updateSystemSetting);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as usersController from '../controllers/users.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { isAdmin } from '../middleware/rbac.middleware';
|
||||
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||
import { validate } from '../middleware/validate.middleware';
|
||||
import { createUserSchema } from '../utils/validators';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -13,6 +15,7 @@ router.use(activityLogger);
|
||||
router.get('/simple', usersController.getUsersSimple);
|
||||
|
||||
router.get('/', isAdmin, usersController.getUsers);
|
||||
router.post('/', isAdmin, validate(createUserSchema), usersController.createUser);
|
||||
router.get('/:id', isAdmin, usersController.getUser);
|
||||
router.put('/:id', isAdmin, usersController.updateUser);
|
||||
router.delete('/:id', isAdmin, usersController.deleteUser);
|
||||
|
||||
@@ -144,15 +144,19 @@ export const getZakazkaById = async (rok: number, id: number): Promise<Zakazka |
|
||||
return zakazky.find((z) => z.id === id) || null;
|
||||
};
|
||||
|
||||
// Normalize text for search (remove diacritics, lowercase)
|
||||
const normalizeText = (text: string): string =>
|
||||
text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
|
||||
// Search zakazky
|
||||
export const searchZakazky = async (rok: number, search: string): Promise<Zakazka[]> => {
|
||||
const zakazky = await getZakazkyByYear(rok);
|
||||
const searchLower = search.toLowerCase();
|
||||
const searchNormalized = normalizeText(search);
|
||||
|
||||
return zakazky.filter((z) =>
|
||||
z.cislo.toLowerCase().includes(searchLower) ||
|
||||
z.nazov.toLowerCase().includes(searchLower) ||
|
||||
z.customer.toLowerCase().includes(searchLower)
|
||||
normalizeText(z.cislo).includes(searchNormalized) ||
|
||||
normalizeText(z.nazov).includes(searchNormalized) ||
|
||||
normalizeText(z.customer).includes(searchNormalized)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
324
backend/src/services/notification.service.ts
Normal file
324
backend/src/services/notification.service.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import prisma from '../config/database';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export enum NotificationType {
|
||||
TASK_ASSIGNED = 'TASK_ASSIGNED',
|
||||
TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED',
|
||||
TASK_COMMENT = 'TASK_COMMENT',
|
||||
TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING',
|
||||
TASK_UPDATED = 'TASK_UPDATED',
|
||||
RMA_ASSIGNED = 'RMA_ASSIGNED',
|
||||
RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED',
|
||||
RMA_COMMENT = 'RMA_COMMENT',
|
||||
}
|
||||
|
||||
interface CreateNotificationData {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
taskId?: string;
|
||||
rmaId?: string;
|
||||
data?: Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
export const notificationService = {
|
||||
// Create a new notification
|
||||
async create(data: CreateNotificationData) {
|
||||
return prisma.notification.create({
|
||||
data: {
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
taskId: data.taskId,
|
||||
rmaId: data.rmaId,
|
||||
data: data.data || undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Create notifications for multiple users
|
||||
async createForUsers(userIds: string[], data: Omit<CreateNotificationData, 'userId'>) {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
return prisma.notification.createMany({
|
||||
data: userIds.map((userId) => ({
|
||||
userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
message: data.message,
|
||||
taskId: data.taskId,
|
||||
rmaId: data.rmaId,
|
||||
data: data.data || undefined,
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
// Get notifications for a user
|
||||
async getForUser(userId: string, options?: { limit?: number; offset?: number; unreadOnly?: boolean }) {
|
||||
const { limit = 50, offset = 0, unreadOnly = false } = options || {};
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
...(unreadOnly ? { isRead: false } : {}),
|
||||
OR: [
|
||||
{ snoozedUntil: null },
|
||||
{ snoozedUntil: { lte: new Date() } },
|
||||
],
|
||||
};
|
||||
|
||||
const [rawNotifications, total] = await Promise.all([
|
||||
prisma.notification.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include: {
|
||||
task: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: { select: { id: true, name: true, color: true } },
|
||||
project: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
rma: {
|
||||
select: {
|
||||
id: true,
|
||||
rmaNumber: true,
|
||||
productName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.notification.count({ where }),
|
||||
]);
|
||||
|
||||
// Pre TASK_COMMENT notifikácie načítaj text komentára z Comment tabuľky
|
||||
const notifications = await Promise.all(
|
||||
rawNotifications.map(async (notification) => {
|
||||
if (notification.type === 'TASK_COMMENT' && notification.taskId) {
|
||||
const data = notification.data as { commentId?: string; actorName?: string } | null;
|
||||
|
||||
let comment;
|
||||
if (data?.commentId) {
|
||||
// Nové notifikácie - načítaj podľa commentId
|
||||
comment = await prisma.comment.findUnique({
|
||||
where: { id: data.commentId },
|
||||
select: { content: true, user: { select: { name: true } } },
|
||||
});
|
||||
} else {
|
||||
// Staré notifikácie - nájdi podľa času (±5 sekúnd)
|
||||
const notifTime = notification.createdAt.getTime();
|
||||
comment = await prisma.comment.findFirst({
|
||||
where: {
|
||||
taskId: notification.taskId,
|
||||
createdAt: {
|
||||
gte: new Date(notifTime - 5000),
|
||||
lte: new Date(notifTime + 5000),
|
||||
},
|
||||
},
|
||||
select: { content: true, user: { select: { name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
const shortComment = comment.content.length > 100
|
||||
? comment.content.substring(0, 100) + '...'
|
||||
: comment.content;
|
||||
|
||||
return {
|
||||
...notification,
|
||||
message: shortComment,
|
||||
data: { ...data, actorName: data?.actorName || comment.user?.name },
|
||||
};
|
||||
}
|
||||
}
|
||||
return notification;
|
||||
})
|
||||
);
|
||||
|
||||
return { notifications, total };
|
||||
},
|
||||
|
||||
// Get unread count for a user
|
||||
async getUnreadCount(userId: string) {
|
||||
return prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false,
|
||||
OR: [
|
||||
{ snoozedUntil: null },
|
||||
{ snoozedUntil: { lte: new Date() } },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Mark notification as read
|
||||
async markAsRead(notificationId: string, userId: string) {
|
||||
return prisma.notification.updateMany({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId, // Security: only owner can mark as read
|
||||
},
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Mark all notifications as read for a user
|
||||
async markAllAsRead(userId: string) {
|
||||
return prisma.notification.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
isRead: false,
|
||||
},
|
||||
data: {
|
||||
isRead: true,
|
||||
readAt: new Date(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Snooze notification (remind later)
|
||||
async snooze(notificationId: string, userId: string, until: Date) {
|
||||
return prisma.notification.updateMany({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
snoozedUntil: until,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Delete notification
|
||||
async delete(notificationId: string, userId: string) {
|
||||
return prisma.notification.deleteMany({
|
||||
where: {
|
||||
id: notificationId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Delete old notifications (cleanup job)
|
||||
async deleteOld(olderThanDays: number = 30) {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
|
||||
return prisma.notification.deleteMany({
|
||||
where: {
|
||||
isRead: true,
|
||||
createdAt: { lt: cutoffDate },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Helper: Notify task assignees about status change
|
||||
async notifyTaskStatusChange(
|
||||
taskId: string,
|
||||
oldStatusName: string,
|
||||
newStatusName: string,
|
||||
changedByUserId: string
|
||||
) {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
include: {
|
||||
assignees: { select: { userId: true } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) return;
|
||||
|
||||
// Get actor name
|
||||
const actor = await prisma.user.findUnique({
|
||||
where: { id: changedByUserId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
// Notify assignees (except the one who made the change)
|
||||
const userIds = task.assignees
|
||||
.map((a: { userId: string }) => a.userId)
|
||||
.filter((id: string) => id !== changedByUserId);
|
||||
|
||||
// Also notify task creator if not already in list
|
||||
if (task.createdById !== changedByUserId && !userIds.includes(task.createdById)) {
|
||||
userIds.push(task.createdById);
|
||||
}
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
await this.createForUsers(userIds, {
|
||||
type: NotificationType.TASK_STATUS_CHANGED,
|
||||
title: 'Zmena stavu úlohy',
|
||||
message: `${oldStatusName} → ${newStatusName}`,
|
||||
taskId: task.id,
|
||||
data: { oldStatus: oldStatusName, newStatus: newStatusName, actorName: actor?.name },
|
||||
});
|
||||
},
|
||||
|
||||
// Helper: Notify user about task assignment
|
||||
async notifyTaskAssignment(taskId: string, assignedUserIds: string[], assignedByUserId: string) {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
select: { id: true, title: true },
|
||||
});
|
||||
|
||||
if (!task) return;
|
||||
|
||||
// Get actor name
|
||||
const actor = await prisma.user.findUnique({
|
||||
where: { id: assignedByUserId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
// Don't notify the user who assigned (if they assigned themselves)
|
||||
const userIds = assignedUserIds.filter((id) => id !== assignedByUserId);
|
||||
|
||||
if (userIds.length === 0) return;
|
||||
|
||||
await this.createForUsers(userIds, {
|
||||
type: NotificationType.TASK_ASSIGNED,
|
||||
title: 'Nová úloha',
|
||||
message: 'Boli ste priradení k úlohe',
|
||||
taskId: task.id,
|
||||
data: { actorName: actor?.name },
|
||||
});
|
||||
},
|
||||
|
||||
// Helper: Notify about new comment on task
|
||||
async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
include: {
|
||||
assignees: { select: { userId: true } },
|
||||
createdBy: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) return;
|
||||
|
||||
// Notify assignees and creator (except comment author)
|
||||
const userIds = new Set<string>();
|
||||
task.assignees.forEach((a: { userId: string }) => userIds.add(a.userId));
|
||||
userIds.add(task.createdById);
|
||||
userIds.delete(commentByUserId);
|
||||
|
||||
if (userIds.size === 0) return;
|
||||
|
||||
await this.createForUsers(Array.from(userIds), {
|
||||
type: NotificationType.TASK_COMMENT,
|
||||
title: 'Nový komentár',
|
||||
message: '', // Text sa načíta z Comment tabuľky
|
||||
taskId: task.id,
|
||||
data: { commentId, actorName: commentByUserName },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -71,3 +71,9 @@ export const parseBooleanQuery = (value: unknown): boolean | undefined => {
|
||||
if (value === 'false') return false;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const parseQueryBoolean = (value: unknown, defaultValue: boolean): boolean => {
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,17 @@ export const registerSchema = z.object({
|
||||
});
|
||||
|
||||
// User validators
|
||||
export const createUserSchema = z.object({
|
||||
email: z.string().email('Neplatný email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Heslo musí mať aspoň 8 znakov')
|
||||
.regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno')
|
||||
.regex(/[0-9]/, 'Heslo musí obsahovať číslo'),
|
||||
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky'),
|
||||
roleId: z.string().min(1, 'Rola je povinná'),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
email: z.string().email('Neplatný email').optional(),
|
||||
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(),
|
||||
|
||||
Reference in New Issue
Block a user