feat(reminders): получатели в отложенных/входящих, приватные сообщения чата, календарь, бейдж уведомлений

This commit is contained in:
2026-09-21 10:26:53 +03:00
parent 17e6ebf412
commit aa316141be
20 changed files with 312 additions and 36 deletions

View File

@@ -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() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="Меню" className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground transition-colors">
<button aria-label="Меню" className="relative flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground transition-colors">
<Menu className="w-5 h-5" aria-hidden="true" />
{notificationsUnread > 0 && (
<span aria-hidden="true" className="absolute top-1 right-1 bg-destructive text-destructive-foreground text-[10px] font-bold rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-0.5 leading-none">
{notificationsUnread > 99 ? '99+' : notificationsUnread}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-52 mb-2">
@@ -333,6 +338,11 @@ export function MobileBottomNav() {
<DropdownMenuItem className="text-xs">
<Bell className="w-3.5 h-3.5 mr-2" />
Уведомления
{notificationsUnread > 0 && (
<span className="ml-auto bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1">
{notificationsUnread > 99 ? '99+' : notificationsUnread}
</span>
)}
</DropdownMenuItem>
</Link>
<Link href="/users">

View File

@@ -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 (
<div key={message.id} className="py-px" data-message-id={message.id}>
<MessageBubble<TaskChatMessage>
@@ -1437,7 +1437,7 @@ const TaskChat = ({ taskId }: TaskChatProps) => {
onReplyClick={scrollToMessage}
authorBadge={!isMyMessage && isSystemLike ? (
<Badge variant="secondary" className="text-xs ml-1 inline">
{message.messageType === 'system' ? 'Система' : 'Статус'}
{message.messageType === 'system' ? 'Система' : message.messageType === 'reminder' ? 'Напоминание' : 'Статус'}
</Badge>
) : undefined}
renderContent={renderMessageContent}

View File

@@ -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}
>
<span className={cn("block truncate font-medium", compact && "text-[10px]")}>
{showTime && task.dueDate && (
<span className="opacity-80 mr-0.5">{formatOrg(task.dueDate, 'HH:mm')}</span>
)}
{task.isReminder && <Bell className="w-3 h-3 inline-block mr-0.5 -mt-0.5" />}
{task.title}
</span>
{!compact && task.statusName && (

View File

@@ -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: [] },
});
}

View File

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

View File

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

View File

@@ -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<CalendarTask[]>(() => {
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();

View File

@@ -102,6 +102,7 @@ interface HomeTask {
unreadNotifications?: number;
lastInteractionAt?: string;
remindAt?: string;
createdByName?: string;
hiddenAt?: string;
fieldValues?: Record<string, unknown>;
}
@@ -409,7 +410,10 @@ function TaskTableRow({
<Eye className="w-3 h-3" />Вернуть
</button>
) : showRemindAt && task.remindAt ? (
<span className="text-[11px] text-warning-foreground font-medium">{formatRemindAt(task.remindAt)}</span>
<span className="text-[11px] text-warning-foreground font-medium">
{formatRemindAt(task.remindAt)}
{task.createdByName ? <span className="text-muted-foreground font-normal"> · от {task.createdByName}</span> : null}
</span>
) : (
<span className="text-[11px] text-muted-foreground">{formatRelativeDate(dateStr)}</span>
)}

View File

@@ -0,0 +1,6 @@
-- Приватные сообщения в чате задачи: ограничение видимости по пользователям.
-- NULL = видят все участники чата (прежнее поведение);
-- массив user id = сообщение видят только перечисленные пользователи (+ автор).
-- Используется для сообщений о напоминаниях (только получатели + создатель),
-- в перспективе — для общения с заказчиком и др. приватных веток.
ALTER TABLE task_messages ADD COLUMN IF NOT EXISTS visible_to_user_ids JSONB;

View File

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

View File

@@ -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: "Ошибка при получении задач календаря" });

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TaskMessage> {
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<Ta
messageType,
mentionedUserIds: mentionedUserIds.length > 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<Ta
}
}
eventBus.publishEvent({
type: 'message_created',
data: { taskId: taskId, message: createdMessage },
organizationId,
taskId: taskId
});
// Приватные сообщения (visible_to_user_ids): SSE-событие получают только
// разрешённые пользователи (перечисленные + автор). Остальные участники чата
// событие не получают и сообщение не увидят ни в реальном времени, ни при загрузке
// (фильтр в storage.getTaskMessages по viewerUserId).
const visibleTo = messageData.visibleToUserIds;
if (visibleTo && visibleTo.length > 0) {
const allowed = new Set<number>(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).

View File

@@ -217,7 +217,7 @@ export interface IStorage {
createFieldHistory(insertHistory: InsertFieldHistory): Promise<FieldHistory>;
// Task Messages (Chat)
getTaskMessages(taskId: number, organizationId: number, afterId?: number): Promise<TaskMessageWithAuthor[]>;
getTaskMessages(taskId: number, organizationId: number, afterId?: number, viewerUserId?: number): Promise<TaskMessageWithAuthor[]>;
getTaskMessage(messageId: number, organizationId: number): Promise<TaskMessageWithAuthor | undefined>;
getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise<TaskMessage[]>;
getTaskMessagesByTaskIds(taskIds: number[], organizationId: number): Promise<TaskMessage[]>;
@@ -252,6 +252,7 @@ export interface IStorage {
batchUpdateBookmarkPositions(items: { id: number; position: number }[], organizationId: number, userId: number): Promise<void>;
// User Notifications
getUnreadNotificationCount(userId: number, organizationId: number): Promise<number>;
getUserNotifications(userId: number, organizationId: number, isRead?: boolean): Promise<UserNotification[]>;
createUserNotification(insertNotification: InsertUserNotification): Promise<UserNotification>;
markNotificationAsRead(id: number, userId: number, organizationId: number): Promise<UserNotification>;

View File

@@ -267,6 +267,18 @@ export class SocialStorage extends TasksStorage {
}
// User Notifications
async getUnreadNotificationCount(userId: number, organizationId: number): Promise<number> {
const [row] = await db
.select({ count: sql<number>`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<UserNotification[]> {
const conditions = [
eq(userNotifications.userId, userId),

View File

@@ -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<TaskMessageWithAuthor[]> {
async getTaskMessages(taskId: number, organizationId: number, afterId?: number, viewerUserId?: number): Promise<TaskMessageWithAuthor[]> {
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);

View File

@@ -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<number[]>(), // NULL = видят все участники чата; массив = только перечисленные (+ автор)
attachments: jsonb("attachments").$type<Array<{ url: string; name: string; size: number; mimeType?: string }>>(),
createdAt: timestamp("created_at").defaultNow(),
}, (table) => ({