diff --git a/client/src/components/MobileBottomNav.tsx b/client/src/components/MobileBottomNav.tsx index 31a055d..dc5c150 100644 --- a/client/src/components/MobileBottomNav.tsx +++ b/client/src/components/MobileBottomNav.tsx @@ -114,7 +114,7 @@ export function MobileBottomNav() { const { data: foldersData } = useBookmarkFolders(); const { data: rootBookmarksData } = useBookmarksByFolder(null); - const { messengerUnread: unreadCount, inboxCount } = useUnreadBadges(); + const { messengerUnread: unreadCount, inboxCount, notificationsUnread } = useUnreadBadges(); // Прячем GPS, если у пользователя нет доступа к модулю const { data: gpsConfig } = useQuery<{ success: boolean; allowed: boolean }>({ @@ -324,8 +324,13 @@ export function MobileBottomNav() { - @@ -333,6 +338,11 @@ export function MobileBottomNav() { Уведомления + {notificationsUnread > 0 && ( + + {notificationsUnread > 99 ? '99+' : notificationsUnread} + + )} diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 849dde3..bcb2937 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -55,7 +55,7 @@ interface TaskMessage { taskId: number; replyToMessageId?: number; message: string; - messageType: 'comment' | 'system' | 'status_change' | 'bot'; + messageType: 'comment' | 'system' | 'status_change' | 'bot' | 'reminder'; createdAt: string; mentionedUserIds?: number[]; botId?: number; @@ -1418,7 +1418,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => { // read receipts, inline-редактирование, скролл к ответу с учётом виртуализации) const renderMessage = (message: TaskChatMessage) => { const isMyMessage = !!(user && message.author && message.author.id === user.id); - const isSystemLike = message.messageType === 'system' || message.messageType === 'status_change'; + const isSystemLike = message.messageType === 'system' || message.messageType === 'status_change' || message.messageType === 'reminder'; return (
@@ -1437,7 +1437,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => { onReplyClick={scrollToMessage} authorBadge={!isMyMessage && isSystemLike ? ( - {message.messageType === 'system' ? 'Система' : 'Статус'} + {message.messageType === 'system' ? 'Система' : message.messageType === 'reminder' ? 'Напоминание' : 'Статус'} ) : undefined} renderContent={renderMessageContent} diff --git a/client/src/components/calendar/CalendarTaskCard.tsx b/client/src/components/calendar/CalendarTaskCard.tsx index 40f644b..166e412 100644 --- a/client/src/components/calendar/CalendarTaskCard.tsx +++ b/client/src/components/calendar/CalendarTaskCard.tsx @@ -1,4 +1,5 @@ import { useDraggable } from '@dnd-kit/core'; +import { Bell } from 'lucide-react'; import { cn } from '@/lib/utils'; import { formatOrg } from '@/lib/timezone'; import type { CalendarTask } from '@/hooks/useCalendarTasks'; @@ -12,8 +13,10 @@ interface CalendarTaskCardProps { export function CalendarTaskCard({ task, onClick, compact, showTime }: CalendarTaskCardProps) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ - id: `calendar-task-${task.id}`, + id: `calendar-task-${task.id}${task.isReminder ? '-reminder' : ''}`, data: { task }, + // События-напоминания не переносятся drag&drop (менять remindAt перетаскиванием нельзя) + disabled: !!task.isReminder, }); return ( @@ -30,19 +33,21 @@ export function CalendarTaskCard({ task, onClick, compact, showTime }: CalendarT "w-full text-left rounded px-1.5 py-1 text-xs leading-tight shadow-sm border", "hover:brightness-95 active:scale-[0.98] transition-transform", "focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", - isDragging && "opacity-50 cursor-grabbing z-50" + isDragging && "opacity-50 cursor-grabbing z-50", + task.isReminder && "border-dashed" )} style={{ backgroundColor: task.statusColor || '#e5e7eb', borderColor: 'rgba(0,0,0,0.08)', color: '#0f172a', }} - title={task.title} + title={task.isReminder ? `Напоминание: ${task.title}` : task.title} > {showTime && task.dueDate && ( {formatOrg(task.dueDate, 'HH:mm')} )} + {task.isReminder && } {task.title} {!compact && task.statusName && ( diff --git a/client/src/hooks/useCalendarTasks.ts b/client/src/hooks/useCalendarTasks.ts index 6ed9268..3b9b5b8 100644 --- a/client/src/hooks/useCalendarTasks.ts +++ b/client/src/hooks/useCalendarTasks.ts @@ -12,11 +12,24 @@ export interface CalendarTask { statusName: string | null; isCompleted: boolean; isAllDay: boolean; + // Событие-напоминание (по дате remindAt): не draggable, клик открывает задачу + isReminder?: boolean; +} + +// Напоминание на задаче, где текущий пользователь — автор или получатель +export interface CalendarReminder { + taskId: number; + title: string; + remindAt: string; + formId: number; + statusName: string | null; + statusColor: string | null; } interface CalendarTasksResponse { success: boolean; tasks: CalendarTask[]; + reminders: CalendarReminder[]; } function buildCalendarQueryKey(from: Date, to: Date, formIds: number[]) { @@ -37,7 +50,7 @@ export function useCalendarTasks(from: Date, to: Date, formIds: number[]) { return res.json(); }, enabled: formIds.length > 0 && !isNaN(from.getTime()) && !isNaN(to.getTime()), - placeholderData: { success: true, tasks: [] }, + placeholderData: { success: true, tasks: [], reminders: [] }, }); } diff --git a/client/src/hooks/useUnreadBadges.ts b/client/src/hooks/useUnreadBadges.ts index 87b23b0..7a24ec8 100644 --- a/client/src/hooks/useUnreadBadges.ts +++ b/client/src/hooks/useUnreadBadges.ts @@ -40,6 +40,7 @@ function ensureBadgeSseHandlers() { const invalidateInboxCount = () => { queryClient.invalidateQueries({ queryKey: ['/api/home/inbox/count'] }); + queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] }); }; sseManager.on('notification', invalidateInboxCount); sseManager.on('task_updated', invalidateInboxCount); @@ -69,9 +70,16 @@ export function useUnreadBadges() { staleTime: 10000, }); + const { data: notificationsData } = useQuery<{ count: number }>({ + queryKey: ['/api/notifications/unread-count'], + refetchInterval: isConnected ? false : 30000, + staleTime: 10000, + }); + return { messengerUnread: messengerData?.unreadCount ?? 0, inboxCount: inboxData?.count ?? 0, + notificationsUnread: notificationsData?.count ?? 0, isConnected, }; } diff --git a/client/src/lib/notification-utils.tsx b/client/src/lib/notification-utils.tsx index ad4fbbf..3756fb8 100644 --- a/client/src/lib/notification-utils.tsx +++ b/client/src/lib/notification-utils.tsx @@ -80,6 +80,7 @@ export function useMarkNotificationRead() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); + queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] }); }, }); } @@ -110,6 +111,7 @@ export function useMarkAllNotificationsRead() { }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); + queryClient.invalidateQueries({ queryKey: ['/api/notifications/unread-count'] }); }, }); } diff --git a/client/src/pages/CalendarPage.tsx b/client/src/pages/CalendarPage.tsx index c3ffe1e..22dbbd2 100644 --- a/client/src/pages/CalendarPage.tsx +++ b/client/src/pages/CalendarPage.tsx @@ -93,7 +93,23 @@ export default function CalendarPage() { }, [currentDate, view]); const { data: tasksData, isLoading } = useCalendarTasks(from, to, selectedFormIds); - const tasks = tasksData?.tasks || []; + // Напоминания подмешиваются как обычные события (флаг isReminder — без drag&drop) + const tasks = useMemo(() => { + const base = tasksData?.tasks || []; + const reminderTasks: CalendarTask[] = (tasksData?.reminders || []).map((r) => ({ + id: r.taskId, + title: r.title, + dueDate: r.remindAt, + formId: r.formId, + currentStatusId: 0, + statusColor: r.statusColor, + statusName: r.statusName, + isCompleted: false, + isAllDay: false, + isReminder: true, + })); + return [...base, ...reminderTasks]; + }, [tasksData]); const updateDueDate = useUpdateCalendarTaskDueDate(); diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 774ccd3..7782be1 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -102,6 +102,7 @@ interface HomeTask { unreadNotifications?: number; lastInteractionAt?: string; remindAt?: string; + createdByName?: string; hiddenAt?: string; fieldValues?: Record; } @@ -409,7 +410,10 @@ function TaskTableRow({ Вернуть ) : showRemindAt && task.remindAt ? ( - {formatRemindAt(task.remindAt)} + + {formatRemindAt(task.remindAt)} + {task.createdByName ? · от {task.createdByName} : null} + ) : ( {formatRelativeDate(dateStr)} )} diff --git a/migrations/0084_task_message_visibility.sql b/migrations/0084_task_message_visibility.sql new file mode 100644 index 0000000..17321ab --- /dev/null +++ b/migrations/0084_task_message_visibility.sql @@ -0,0 +1,6 @@ +-- Приватные сообщения в чате задачи: ограничение видимости по пользователям. +-- NULL = видят все участники чата (прежнее поведение); +-- массив user id = сообщение видят только перечисленные пользователи (+ автор). +-- Используется для сообщений о напоминаниях (только получатели + создатель), +-- в перспективе — для общения с заказчиком и др. приватных веток. +ALTER TABLE task_messages ADD COLUMN IF NOT EXISTS visible_to_user_ids JSONB; diff --git a/server/index.ts b/server/index.ts index bea9b43..c1338d0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { webPushService } from "./services/web-push.service"; import { startMedScheduleWorker } from "./medschedule/worker"; import { startAutomationScheduler } from "./workers/automation-scheduler"; import { startDelayedStatusChangeWorker } from "./workers/delayed-status-change.worker"; +import { sendTaskMessage } from "./services/task-message.service"; import { startErrorLogsRetention } from "./workers/error-logs-retention"; import { startGpsWorker } from "./gps/worker"; import { storage } from "./storage"; @@ -1225,6 +1226,21 @@ async function runStartupDataPatches() { } } await withSuperAdmin(() => storage.markReminderSent(reminder.id)); + + // Сообщение в чат задачи с текстом напоминания (бейдж «Напоминание»). + // Видят только получатели напоминания и его создатель (visible_to_user_ids). + if (task) { + const visibleTo = [...new Set([...recipientUserIds, reminder.createdByUserId])]; + sendTaskMessage({ + task, + organizationId: reminder.organizationId, + message: reminder.note ? `Напоминание: ${reminder.note}` : `Напоминание о задаче "${taskTitle}"`, + messageType: 'reminder', + visibleToUserIds: visibleTo, + }).catch((chatErr: unknown) => log( + `Reminder worker: chat message failed for reminder ${reminder.id}: ${chatErr instanceof Error ? chatErr.message : String(chatErr)}` + )); + } } catch (reminderErr) { log(`Reminder worker: failed to process reminder ${reminder.id}: ${reminderErr instanceof Error ? reminderErr.message : String(reminderErr)}`); await withSuperAdmin(() => storage.markReminderSent(reminder.id)).catch(() => {}); diff --git a/server/routes/calendar.routes.ts b/server/routes/calendar.routes.ts index fdea9ee..3582b88 100644 --- a/server/routes/calendar.routes.ts +++ b/server/routes/calendar.routes.ts @@ -2,7 +2,8 @@ import { Router } from "express"; import { db } from "../db"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; -import { tasks, forms, formStatuses, taskAssignees } from "@shared/schema"; +import { tasks, forms, formStatuses, taskAssignees, taskReminders, type ReminderRecipient } from "@shared/schema"; +import { storage } from "../storage"; import { eq, and, or, gte, lte, sql, inArray, isNotNull, asc, exists } from "drizzle-orm"; export const calendarRouter = Router(); @@ -19,6 +20,16 @@ interface CalendarTask { isAllDay: boolean; } +// Напоминание на задаче (событие по дате remindAt, drag&drop не применим) +interface CalendarReminder { + taskId: number; + title: string; + remindAt: string; + formId: number; + statusName: string | null; + statusColor: string | null; +} + function parseDateParam(value: unknown): Date | null { if (typeof value !== "string" || !value) return null; const d = new Date(value); @@ -110,7 +121,64 @@ calendarRouter.get("/api/calendar/tasks", authenticateToken, tenantIsolation, as }; }); - res.json({ success: true, tasks: mapped }); + // Задачи с напоминаниями, где текущий пользователь — автор или получатель. + // Возвращаются отдельным массивом: событие по дате remindAt (без drag&drop). + const reminderRows = await db + .select({ + taskId: tasks.id, + title: tasks.title, + remindAt: taskReminders.remindAt, + formId: tasks.formId, + statusName: formStatuses.name, + statusColor: formStatuses.color, + recipients: taskReminders.recipients, + createdByUserId: taskReminders.createdByUserId, + }) + .from(taskReminders) + .innerJoin(tasks, eq(taskReminders.taskId, tasks.id)) + .innerJoin(forms, eq(tasks.formId, forms.id)) + .innerJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id)) + .where(and( + eq(taskReminders.organizationId, organizationId), + eq(taskReminders.isSent, false), + gte(taskReminders.remindAt, from), + lte(taskReminders.remindAt, to), + eq(tasks.isCompleted, false), + formIds ? inArray(tasks.formId, formIds) : sql`true`, + )); + + const reminders: CalendarReminder[] = []; + for (const row of reminderRows) { + let isRecipient = false; + if (row.createdByUserId === userId) { + isRecipient = true; + } else { + const recips = (row.recipients || []) as ReminderRecipient[]; + isRecipient = recips.some(r => r.type === 'user' && r.userId === userId); + if (!isRecipient) { + for (const r of recips) { + if (r.type === 'role') { + const us = await storage.getUsersByRole(r.role, organizationId); + if (us.some(u => u.id === userId)) { isRecipient = true; break; } + } else if (r.type === 'orgRole') { + const us = await storage.getUsersByOrgRoleId(r.roleId, organizationId); + if (us.some(u => u.id === userId)) { isRecipient = true; break; } + } + } + } + } + if (!isRecipient || !row.remindAt) continue; + reminders.push({ + taskId: row.taskId, + title: row.title, + remindAt: row.remindAt.toISOString(), + formId: row.formId, + statusName: row.statusName, + statusColor: row.statusColor, + }); + } + + res.json({ success: true, tasks: mapped, reminders }); } catch (error) { console.error("Calendar tasks error:", error); res.status(500).json({ success: false, error: "Ошибка при получении задач календаря" }); diff --git a/server/routes/chat.messages.routes.ts b/server/routes/chat.messages.routes.ts index 84eb8b1..e57e610 100644 --- a/server/routes/chat.messages.routes.ts +++ b/server/routes/chat.messages.routes.ts @@ -32,7 +32,7 @@ export function registerChatMessageRoutes(router: Router): void { } const afterId = req.query.afterId ? parseInt(req.query.afterId as string) : undefined; - const messages = await storage.getTaskMessages(taskId, req.organizationId!, afterId); + const messages = await storage.getTaskMessages(taskId, req.organizationId!, afterId, req.user!.id); res.json({ success: true, messages }); } catch (error) { console.error('Get task messages error:', error); diff --git a/server/routes/chat.notifications.routes.ts b/server/routes/chat.notifications.routes.ts index ee4b7a0..6692cb4 100644 --- a/server/routes/chat.notifications.routes.ts +++ b/server/routes/chat.notifications.routes.ts @@ -27,6 +27,21 @@ export function registerChatNotificationRoutes(router: Router): void { } ); + // Счётчик непрочитанных уведомлений (бейдж в навигации) + router.get('/api/notifications/unread-count', + authenticateToken, + tenantIsolation, + async (req: AuthenticatedRequest, res) => { + try { + const count = await storage.getUnreadNotificationCount(req.user!.id, req.organizationId!); + res.json({ count }); + } catch (error) { + console.error('Get unread notification count error:', error); + res.status(500).json({ success: false, count: 0 }); + } + } + ); + // Mark notification as read router.put('/api/notifications/:id/read', authenticateToken, diff --git a/server/routes/home.routes.ts b/server/routes/home.routes.ts index ef87648..8358884 100644 --- a/server/routes/home.routes.ts +++ b/server/routes/home.routes.ts @@ -170,7 +170,20 @@ homeRouter.get('/api/home/inbox', AND NOT EXISTS ( SELECT 1 FROM task_reminders tr_snooze WHERE tr_snooze.task_id = t.id - AND tr_snooze.created_by_user_id = $2 + AND ( + tr_snooze.created_by_user_id = $2 + OR tr_snooze.recipients @> jsonb_build_array(jsonb_build_object('type', 'user', 'userId', $2)) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'role' + AND r_el ->> 'role' = (SELECT app_role FROM users WHERE id = $2) + ) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'orgRole' + AND EXISTS (SELECT 1 FROM role_members rm WHERE rm.role_id = (r_el ->> 'roleId')::int AND rm.user_id = $2) + ) + ) AND tr_snooze.remind_at > NOW() AND tr_snooze.is_sent = false ) @@ -217,7 +230,20 @@ homeRouter.get('/api/home/inbox', AND NOT EXISTS ( SELECT 1 FROM task_reminders tr_snooze WHERE tr_snooze.task_id = t.id - AND tr_snooze.created_by_user_id = $2 + AND ( + tr_snooze.created_by_user_id = $2 + OR tr_snooze.recipients @> jsonb_build_array(jsonb_build_object('type', 'user', 'userId', $2)) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'role' + AND r_el ->> 'role' = (SELECT app_role FROM users WHERE id = $2) + ) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'orgRole' + AND EXISTS (SELECT 1 FROM role_members rm WHERE rm.role_id = (r_el ->> 'roleId')::int AND rm.user_id = $2) + ) + ) AND tr_snooze.remind_at > NOW() AND tr_snooze.is_sent = false ) @@ -400,7 +426,20 @@ homeRouter.get('/api/home/inbox/count', WHERE NOT EXISTS ( SELECT 1 FROM task_reminders tr_snooze WHERE tr_snooze.task_id = s.task_id - AND tr_snooze.created_by_user_id = $2 + AND ( + tr_snooze.created_by_user_id = $2 + OR tr_snooze.recipients @> jsonb_build_array(jsonb_build_object('type', 'user', 'userId', $2)) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'role' + AND r_el ->> 'role' = (SELECT app_role FROM users WHERE id = $2) + ) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr_snooze.recipients) r_el + WHERE r_el ->> 'type' = 'orgRole' + AND EXISTS (SELECT 1 FROM role_members rm WHERE rm.role_id = (r_el ->> 'roleId')::int AND rm.user_id = $2) + ) + ) AND tr_snooze.remind_at > NOW() AND tr_snooze.is_sent = false ) @@ -448,14 +487,29 @@ homeRouter.get('/api/home/snoozed', t.updated_at AS "updatedAt", t.due_date AS "dueDate", t.is_completed AS "isCompleted", - MIN(tr.remind_at) OVER (PARTITION BY t.id) AS "remindAt" + MIN(tr.remind_at) OVER (PARTITION BY t.id) AS "remindAt", + CONCAT(cu.first_name, ' ', cu.last_name) AS "createdByName" FROM task_reminders tr JOIN tasks t ON t.id = tr.task_id AND t.organization_id = $1 JOIN forms f ON f.id = t.form_id JOIN form_statuses fs ON fs.id = t.current_status_id LEFT JOIN users u ON u.id = t.assigned_to + LEFT JOIN users cu ON cu.id = tr.created_by_user_id WHERE tr.organization_id = $1 - AND tr.created_by_user_id = $2 + AND ( + tr.created_by_user_id = $2 + OR tr.recipients @> jsonb_build_array(jsonb_build_object('type', 'user', 'userId', $2)) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr.recipients) r_el + WHERE r_el ->> 'type' = 'role' + AND r_el ->> 'role' = (SELECT app_role FROM users WHERE id = $2) + ) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr.recipients) r_el + WHERE r_el ->> 'type' = 'orgRole' + AND EXISTS (SELECT 1 FROM role_members rm WHERE rm.role_id = (r_el ->> 'roleId')::int AND rm.user_id = $2) + ) + ) AND tr.remind_at > NOW() AND tr.is_sent = false AND ($3 = '' OR t.title ILIKE '%' || $3 || '%') @@ -468,7 +522,20 @@ homeRouter.get('/api/home/snoozed', FROM task_reminders tr JOIN tasks t ON t.id = tr.task_id AND t.organization_id = $1 WHERE tr.organization_id = $1 - AND tr.created_by_user_id = $2 + AND ( + tr.created_by_user_id = $2 + OR tr.recipients @> jsonb_build_array(jsonb_build_object('type', 'user', 'userId', $2)) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr.recipients) r_el + WHERE r_el ->> 'type' = 'role' + AND r_el ->> 'role' = (SELECT app_role FROM users WHERE id = $2) + ) + OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(tr.recipients) r_el + WHERE r_el ->> 'type' = 'orgRole' + AND EXISTS (SELECT 1 FROM role_members rm WHERE rm.role_id = (r_el ->> 'roleId')::int AND rm.user_id = $2) + ) + ) AND tr.remind_at > NOW() AND tr.is_sent = false AND ($3 = '' OR t.title ILIKE '%' || $3 || '%') diff --git a/server/services/notification.service.ts b/server/services/notification.service.ts index fb2da9e..4bed831 100644 --- a/server/services/notification.service.ts +++ b/server/services/notification.service.ts @@ -444,7 +444,9 @@ export class NotificationService { formId: data.formId.toString(), isChat: isChatNotification ? 'true' : 'false', ...(data.messageId ? { messageId: data.messageId.toString() } : {}), - type: 'chat', + // Чатовые уведомления сохраняем с type 'chat' (SW-обработчики кнопок «Ответить»/«Прочитано»), + // остальные — с реальным типом, чтобы не глушились suppression-логикой активного чата + type: isChatNotification ? 'chat' : (data.type || 'notification'), } : undefined; diff --git a/server/services/task-message.service.ts b/server/services/task-message.service.ts index 32744bc..26aae3f 100644 --- a/server/services/task-message.service.ts +++ b/server/services/task-message.service.ts @@ -27,13 +27,14 @@ export interface SendTaskMessageParams { botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot' organizationId: number; message: string; - messageType?: string; // 'comment' (default), 'bot', 'system', ... + messageType?: string; // 'comment' (default), 'bot', 'system', 'status_change', 'reminder', ... replyToMessageId?: number | null; mentionedUserIds?: number[] | null; mentionedBotIds?: number[] | null; attachments?: MessageAttachment[] | null; bodyAuthorId?: number | null; // authorId из тела запроса (null — признак бот/системного сообщения) isBotToken?: boolean; // запрос аутентифицирован bot-service JWT + visibleToUserIds?: number[] | null; // приватность: только перечисленные пользователи видят сообщение (NULL — все участники чата) } // Создаёт сообщение задачи со всеми side-эффектами: @@ -47,10 +48,10 @@ export interface SendTaskMessageParams { export async function sendTaskMessage(params: SendTaskMessageParams): Promise { const { task, user, organizationId } = params; const botId = params.botId ?? null; - // Системные сообщения ('system', 'status_change') могут быть без автора — - // их пишут автоматизации (ctx.tasks.sendMessage), автор в чате — «Система» + // Системные сообщения ('system', 'status_change', 'reminder') могут быть без автора — + // их пишут автоматизации (ctx.tasks.sendMessage, напоминания), автор в чате — «Система» const isAuthorlessSystem = !user && !botId && - (params.messageType === 'system' || params.messageType === 'status_change'); + (params.messageType === 'system' || params.messageType === 'status_change' || params.messageType === 'reminder'); if (!user && !botId && !isAuthorlessSystem) { throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500); } @@ -105,6 +106,9 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise 0 ? mentionedUserIds : null, attachments: params.attachments?.length ? params.attachments : null, + visibleToUserIds: params.visibleToUserIds && params.visibleToUserIds.length > 0 + ? [...new Set(params.visibleToUserIds)] + : null, }; const createdMessage = await storage.createTaskMessage(messageData, organizationId); @@ -232,12 +236,30 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise 0) { + const allowed = new Set(visibleTo); + if (user?.id) allowed.add(user.id); + for (const uid of allowed) { + eventBus.publishEvent({ + type: 'message_created', + data: { taskId: taskId, message: createdMessage }, + organizationId, + userId: uid, + }); + } + } else { + eventBus.publishEvent({ + type: 'message_created', + data: { taskId: taskId, message: createdMessage }, + organizationId, + taskId: taskId + }); + } // Уведомления и исходящие вебхуки — только для сообщений от пользователя // (для bot-сообщений — как в POST /api/bot/message: без notificationService). diff --git a/server/storage.ts b/server/storage.ts index 56c9e83..aab5fdd 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -217,7 +217,7 @@ export interface IStorage { createFieldHistory(insertHistory: InsertFieldHistory): Promise; // Task Messages (Chat) - getTaskMessages(taskId: number, organizationId: number, afterId?: number): Promise; + getTaskMessages(taskId: number, organizationId: number, afterId?: number, viewerUserId?: number): Promise; getTaskMessage(messageId: number, organizationId: number): Promise; getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise; getTaskMessagesByTaskIds(taskIds: number[], organizationId: number): Promise; @@ -252,6 +252,7 @@ export interface IStorage { batchUpdateBookmarkPositions(items: { id: number; position: number }[], organizationId: number, userId: number): Promise; // User Notifications + getUnreadNotificationCount(userId: number, organizationId: number): Promise; getUserNotifications(userId: number, organizationId: number, isRead?: boolean): Promise; createUserNotification(insertNotification: InsertUserNotification): Promise; markNotificationAsRead(id: number, userId: number, organizationId: number): Promise; diff --git a/server/storage/social.storage.ts b/server/storage/social.storage.ts index 80c4586..a9dec96 100644 --- a/server/storage/social.storage.ts +++ b/server/storage/social.storage.ts @@ -267,6 +267,18 @@ export class SocialStorage extends TasksStorage { } // User Notifications + async getUnreadNotificationCount(userId: number, organizationId: number): Promise { + const [row] = await db + .select({ count: sql`COUNT(*)::int` }) + .from(userNotifications) + .where(and( + eq(userNotifications.userId, userId), + eq(userNotifications.organizationId, organizationId), + eq(userNotifications.isRead, false), + )); + return row?.count ?? 0; + } + async getUserNotifications(userId: number, organizationId: number, isRead?: boolean): Promise { const conditions = [ eq(userNotifications.userId, userId), diff --git a/server/storage/tasks.storage.ts b/server/storage/tasks.storage.ts index c1b765d..db1dba7 100644 --- a/server/storage/tasks.storage.ts +++ b/server/storage/tasks.storage.ts @@ -2,7 +2,7 @@ import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews import { taskRelations } from "@shared/schema"; import { taskAssignees, type TaskAssignee } from "@shared/schema"; import { db } from "../db"; -import { eq, and, desc, exists, sql, gt, inArray } from "drizzle-orm"; +import { eq, and, desc, exists, sql, gt, inArray, isNull, or } from "drizzle-orm"; import { formatUserName } from "../utils/formatUserName"; import { TasksCoreStorage } from "./tasks-core.storage"; import { fieldConditionsMatch, resolveFieldDisplayText } from "../utils/field-conditions"; @@ -239,7 +239,7 @@ export class TasksStorage extends TasksCoreStorage { } // Task Messages (Chat) - async getTaskMessages(taskId: number, organizationId: number, afterId?: number): Promise { + async getTaskMessages(taskId: number, organizationId: number, afterId?: number, viewerUserId?: number): Promise { const replyToMsg = db.select({ id: taskMessages.id, message: taskMessages.message, @@ -290,7 +290,15 @@ export class TasksStorage extends TasksCoreStorage { .where(and( eq(taskMessages.taskId, taskId), eq(forms.organizationId, organizationId), - ...(afterId ? [gt(taskMessages.id, afterId)] : []) + ...(afterId ? [gt(taskMessages.id, afterId)] : []), + // Приватные сообщения (visible_to_user_ids) — только для перечисленных пользователей. + // Вызовы без viewerUserId (AI-бот, MCP, внутренние) получают полную ленту. + ...(viewerUserId + ? [or( + isNull(taskMessages.visibleToUserIds), + sql`${taskMessages.visibleToUserIds} @> jsonb_build_array(${viewerUserId}::int)` + )] + : []) )) .orderBy(taskMessages.createdAt); diff --git a/shared/schema.ts b/shared/schema.ts index 864fedb..146f77c 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -737,9 +737,10 @@ export const taskMessages = pgTable("task_messages", { botId: integer("bot_id"), // ID бота-автора (FK добавится после создания таблицы bots) replyToMessageId: integer("reply_to_message_id").references((): AnyPgColumn => taskMessages.id, { onDelete: "cascade" }), // ссылка на сообщение, на которое отвечают message: text("message").notNull().default(""), - messageType: varchar("message_type", { length: 20 }).notNull().default("comment"), // 'comment', 'system', 'status_change', 'bot' + messageType: varchar("message_type", { length: 20 }).notNull().default("comment"), // 'comment', 'system', 'status_change', 'bot', 'reminder' mentionedUserIds: jsonb("mentioned_user_ids"), // массив ID упомянутых пользователей botButtons: jsonb("bot_buttons"), // кнопки для бот-сообщений [{text, callbackData, style, row}] + visibleToUserIds: jsonb("visible_to_user_ids").$type(), // NULL = видят все участники чата; массив = только перечисленные (+ автор) attachments: jsonb("attachments").$type>(), createdAt: timestamp("created_at").defaultNow(), }, (table) => ({