From a4eaf65915db802d6a139a3184bb27651b3c13ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 24 Aug 2026 15:40:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=82=D1=81=D0=BB=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D0=BE=D0=B2=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?:=20task=5Fviews,=20POST=20/api/tasks/:id/view=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?/api/home/recent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/TaskDetail.tsx | 18 ++++++++++++++++++ migrations/0068_task_views.sql | 15 +++++++++++++++ server/routes/home.routes.ts | 9 +++++++++ server/routes/task-crud-extra.routes.ts | 23 +++++++++++++++++++++++ server/storage.ts | 3 +++ server/storage/tasks.storage.ts | 22 +++++++++++++++++++++- shared/schema.ts | 18 ++++++++++++++++++ 7 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 migrations/0068_task_views.sql diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 420750b..275e635 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -3101,6 +3101,24 @@ const TaskDetail = () => { } }, [taskError, setLocation]); + // Record that the user viewed this task so it appears in Home → "Recent". + useEffect(() => { + if (!taskIdNumber || !currentUser) return; + let cancelled = false; + apiRequest('POST', `/api/tasks/${taskIdNumber}/view`) + .then(() => { + if (!cancelled) { + queryClient.invalidateQueries({ queryKey: ['home'] }); + } + }) + .catch((err) => { + console.error('[TaskDetail] Failed to record task view:', err); + }); + return () => { + cancelled = true; + }; + }, [taskIdNumber, currentUser?.id]); + // На мобильном разворачиваем первую пользовательскую вкладку по умолчанию; // если вкладок нет — разворачиваем fallback-секцию «Задача» с полями useEffect(() => { diff --git a/migrations/0068_task_views.sql b/migrations/0068_task_views.sql new file mode 100644 index 0000000..1ba4cad --- /dev/null +++ b/migrations/0068_task_views.sql @@ -0,0 +1,15 @@ +-- Таблица просмотров задач пользователями. +-- Используется для вкладки «Последние» на главной: задачи, которые пользователь открывал. +CREATE TABLE IF NOT EXISTS task_views ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + viewed_at TIMESTAMP DEFAULT NOW(), + UNIQUE (organization_id, user_id, task_id) +); + +CREATE INDEX IF NOT EXISTS task_views_user_viewed_at_idx + ON task_views (organization_id, user_id, viewed_at DESC); +CREATE INDEX IF NOT EXISTS task_views_task_idx + ON task_views (task_id); diff --git a/server/routes/home.routes.ts b/server/routes/home.routes.ts index 51f0928..ef87648 100644 --- a/server/routes/home.routes.ts +++ b/server/routes/home.routes.ts @@ -270,6 +270,13 @@ homeRouter.get('/api/home/recent', WHERE author_id = $2 AND task_id IN (SELECT id FROM tasks WHERE organization_id = $1) GROUP BY task_id + + UNION ALL + + SELECT task_id, MAX(viewed_at) AS last_at + FROM task_views + WHERE organization_id = $1 AND user_id = $2 + GROUP BY task_id ), recent_max AS ( SELECT task_id, MAX(last_at) AS last_at @@ -322,6 +329,8 @@ homeRouter.get('/api/home/recent', SELECT task_id FROM task_messages WHERE author_id = $2 AND task_id IN (SELECT id FROM tasks WHERE organization_id = $1) + UNION + SELECT task_id FROM task_views WHERE organization_id = $1 AND user_id = $2 ), accessible AS ( SELECT task_id FROM task_user_access diff --git a/server/routes/task-crud-extra.routes.ts b/server/routes/task-crud-extra.routes.ts index bd08ae7..7997c67 100644 --- a/server/routes/task-crud-extra.routes.ts +++ b/server/routes/task-crud-extra.routes.ts @@ -21,6 +21,29 @@ export function registerTaskCrudExtraRoutes(router: ReturnType { + try { + const taskId = parseInt(req.params.id); + const userId = req.user!.id; + const organizationId = req.organizationId!; + if (isNaN(taskId)) { + return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); + } + + // Make sure the task exists in this organization before recording a view. + const task = await storage.getTask(taskId, organizationId); + if (!task) { + return res.status(404).json({ success: false, error: 'Задача не найдена' }); + } + + await storage.recordTaskView(taskId, userId, organizationId); + res.json({ success: true }); + } catch (err) { + console.error('[tasks/:id/view]', err); + res.status(500).json({ success: false, error: 'Ошибка сервера' }); + } + }); + router.get('/api/tasks/:id/audit-log', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); diff --git a/server/storage.ts b/server/storage.ts index 49fc527..2f585b8 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -423,6 +423,9 @@ export interface IStorage { addTaskAuditLog(entry: InsertTaskAuditLog): Promise; getTaskAuditLog(taskId: number, organizationId: number): Promise; + // Task Views + recordTaskView(taskId: number, userId: number, organizationId: number): Promise; + // Roles getRoles(organizationId: number): Promise<(import('@shared/schema').Role & { memberCount: number; members: { id: number; userId: number; user: { id: number; firstName: string; lastName: string; email: string } }[]; manager: { id: number; firstName: string; lastName: string; middleName: string | null; email: string } | null })[]>; getRole(id: number, organizationId: number): Promise; diff --git a/server/storage/tasks.storage.ts b/server/storage/tasks.storage.ts index bbc2ece..617dfe3 100644 --- a/server/storage/tasks.storage.ts +++ b/server/storage/tasks.storage.ts @@ -1,4 +1,4 @@ -import { users, forms, tasks, taskMessages, messageReads, bots, roles, type User, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type Task, type TaskFieldValue, type TaskMessage, type TaskMessageWithAuthor, type MessageRead, type DataTableRow } from "@shared/schema"; +import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews, type User, type Form, type FormTab, type FormField, type FormStatus, type StatusTransition, type Task, type TaskFieldValue, type TaskMessage, type TaskMessageWithAuthor, type MessageRead, type DataTableRow } from "@shared/schema"; import { taskRelations } from "@shared/schema"; import { taskAssignees, type TaskAssignee } from "@shared/schema"; import { db } from "../db"; @@ -614,4 +614,24 @@ export class TasksStorage extends TasksCoreStorage { .where(and(eq(taskAssignees.taskId, taskId), eq(taskAssignees.organizationId, organizationId))); return rows; } + + // ===================== + // Task Views (recently viewed tasks for the home "Recent" tab) + // ===================== + + async recordTaskView(taskId: number, userId: number, organizationId: number): Promise { + await db + .insert(taskViews) + .values({ + taskId, + userId, + organizationId, + }) + .onConflictDoUpdate({ + target: [taskViews.organizationId, taskViews.userId, taskViews.taskId], + set: { + viewedAt: sql`CURRENT_TIMESTAMP`, + }, + }); + } } diff --git a/shared/schema.ts b/shared/schema.ts index 02d2d2d..817aef9 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -3123,6 +3123,24 @@ export const taskInboxHidden = pgTable("task_inbox_hidden", { export type TaskInboxHidden = typeof taskInboxHidden.$inferSelect; export type InsertTaskInboxHidden = typeof taskInboxHidden.$inferInsert; +// ===================== +// Task Views (последние просмотренные задачи пользователем) +// ===================== +export const taskViews = pgTable("task_views", { + id: serial("id").primaryKey(), + taskId: integer("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + viewedAt: timestamp("viewed_at").defaultNow(), +}, (table) => ({ + uniqueTaskUser: unique("task_views_task_user_key").on(table.organizationId, table.userId, table.taskId), + userViewedAtIdx: index("task_views_user_viewed_at_idx").on(table.organizationId, table.userId, table.viewedAt.desc()), + taskIdx: index("task_views_task_idx").on(table.taskId), +})); + +export type TaskView = typeof taskViews.$inferSelect; +export type InsertTaskView = typeof taskViews.$inferInsert; + // ===================== // RAG Embeddings (векторная память для AI-агентов) // =====================