Отслеживание просмотров задач: task_views, POST /api/tasks/:id/view и обновление /api/home/recent
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
15
migrations/0068_task_views.sql
Normal file
15
migrations/0068_task_views.sql
Normal file
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,29 @@ export function registerTaskCrudExtraRoutes(router: ReturnType<typeof import("ex
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tasks/:id/view', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||||
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);
|
||||
|
||||
@@ -423,6 +423,9 @@ export interface IStorage {
|
||||
addTaskAuditLog(entry: InsertTaskAuditLog): Promise<TaskAuditLog>;
|
||||
getTaskAuditLog(taskId: number, organizationId: number): Promise<TaskAuditLog[]>;
|
||||
|
||||
// Task Views
|
||||
recordTaskView(taskId: number, userId: number, organizationId: number): Promise<void>;
|
||||
|
||||
// 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<import('@shared/schema').Role | undefined>;
|
||||
|
||||
@@ -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<void> {
|
||||
await db
|
||||
.insert(taskViews)
|
||||
.values({
|
||||
taskId,
|
||||
userId,
|
||||
organizationId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [taskViews.organizationId, taskViews.userId, taskViews.taskId],
|
||||
set: {
|
||||
viewedAt: sql`CURRENT_TIMESTAMP`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-агентов)
|
||||
// =====================
|
||||
|
||||
Reference in New Issue
Block a user