Шаг 0.12 плана production-готовности: - accessibleTasksCache (45с) per userId:orgId внутри storage-метода - инвалидация в storage-слое: роли, делегирования, form access, assignees, createTask/updateTask, appRole пользователя - 9 новых юнит-тестов (65/65 зелёные)
561 lines
20 KiB
TypeScript
561 lines
20 KiB
TypeScript
import { users, forms, formStatuses, tasks, taskFieldValues, fieldHistory, type User, type Form, type FormStatus, type Task, type TaskFieldValue, type FieldHistory, type InsertTask, type InsertTaskFieldValue, type InsertFieldHistory } from "@shared/schema";
|
||
import { taskRelations } from "@shared/schema";
|
||
import { taskAssignees } from "@shared/schema";
|
||
import { db } from "../db";
|
||
import { eq, and, or, desc, asc, exists, sql, ilike, inArray, lt, gte, isNull } from "drizzle-orm";
|
||
import { formatUserName } from "../utils/formatUserName";
|
||
import { FormsStorage } from "./forms.storage";
|
||
import { clampTasksLimit, type TasksCursor } from "../utils/task-pagination";
|
||
import { invalidateAccessibleTasksForUser } from "../utils/cache";
|
||
export class TasksCoreStorage extends FormsStorage {
|
||
// Tasks
|
||
async getTask(id: number, organizationId: number): Promise<Task | undefined> {
|
||
const results = await db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.id, id), eq(forms.organizationId, organizationId)));
|
||
const [result] = results;
|
||
return result?.tasks || undefined;
|
||
}
|
||
|
||
async getTasksByForm(formId: number, organizationId: number): Promise<Task[]> {
|
||
return db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.formId, formId), eq(forms.organizationId, organizationId)))
|
||
.orderBy(desc(tasks.createdAt))
|
||
.then(results => results.map(r => r.tasks));
|
||
}
|
||
|
||
async getTasksWithFieldsByForm(formId: number, organizationId: number): Promise<Task[]> {
|
||
const allTasks = await db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.formId, formId), eq(forms.organizationId, organizationId)))
|
||
.orderBy(desc(tasks.createdAt))
|
||
.then(results => results.map(r => r.tasks));
|
||
|
||
if (allTasks.length === 0) return [];
|
||
|
||
const taskIds = allTasks.map(t => t.id);
|
||
const allFieldValues = await db
|
||
.select()
|
||
.from(taskFieldValues)
|
||
.where(inArray(taskFieldValues.taskId, taskIds));
|
||
|
||
const fieldValuesByTask = new Map<number, { [fieldId: number]: unknown }>();
|
||
for (const fv of allFieldValues) {
|
||
if (!fieldValuesByTask.has(fv.taskId)) {
|
||
fieldValuesByTask.set(fv.taskId, {});
|
||
}
|
||
fieldValuesByTask.get(fv.taskId)![fv.fieldId] = fv.value;
|
||
}
|
||
|
||
return allTasks.map(task => ({
|
||
...task,
|
||
fieldValues: fieldValuesByTask.get(task.id) || {}
|
||
}));
|
||
}
|
||
|
||
async getTasksWithFieldsByFormOptimized(
|
||
formId: number,
|
||
organizationId: number,
|
||
options?: { limit?: number; offset?: number; assignedTo?: number; since?: Date }
|
||
): Promise<{ tasks: Task[]; total: number }> {
|
||
const limit = options?.limit;
|
||
const offset = options?.offset || 0;
|
||
|
||
const whereConditions = [
|
||
eq(tasks.formId, formId),
|
||
eq(forms.organizationId, organizationId),
|
||
];
|
||
if (options?.assignedTo != null) {
|
||
whereConditions.push(eq(tasks.assignedTo, options.assignedTo));
|
||
}
|
||
// Дельта-синк: фильтр по updated_at на уровне SQL (индекс tasks_form_updated_idx).
|
||
// NULL updated_at включаем для паритета со старым JS-фильтром (!t.updatedAt || >= since).
|
||
if (options?.since) {
|
||
whereConditions.push(or(
|
||
gte(tasks.updatedAt, options.since),
|
||
isNull(tasks.updatedAt)
|
||
)!);
|
||
}
|
||
|
||
let query = db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(...whereConditions))
|
||
.orderBy(desc(tasks.updatedAt))
|
||
.offset(offset);
|
||
|
||
const allTasks = limit
|
||
? await query.limit(limit).then(results => results.map(r => r.tasks))
|
||
: await query.then(results => results.map(r => r.tasks));
|
||
|
||
if (allTasks.length === 0) {
|
||
return { tasks: [], total: 0 };
|
||
}
|
||
|
||
const countResult = await db
|
||
.select({ count: sql<number>`count(*)` })
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(...whereConditions));
|
||
const total = Number(countResult[0]?.count || 0);
|
||
|
||
const taskIds = allTasks.map(t => t.id);
|
||
const [allFieldValues, allTaskAssignees] = await Promise.all([
|
||
db.select().from(taskFieldValues).where(inArray(taskFieldValues.taskId, taskIds)),
|
||
db
|
||
.select({
|
||
taskId: taskAssignees.taskId,
|
||
userId: taskAssignees.userId,
|
||
firstName: users.firstName,
|
||
middleName: users.middleName,
|
||
lastName: users.lastName,
|
||
email: users.email,
|
||
})
|
||
.from(taskAssignees)
|
||
.innerJoin(users, eq(taskAssignees.userId, users.id))
|
||
.where(inArray(taskAssignees.taskId, taskIds)),
|
||
]);
|
||
|
||
const fieldValuesByTask = new Map<number, { [fieldId: number]: unknown }>();
|
||
for (const fv of allFieldValues) {
|
||
if (!fieldValuesByTask.has(fv.taskId)) {
|
||
fieldValuesByTask.set(fv.taskId, {});
|
||
}
|
||
fieldValuesByTask.get(fv.taskId)![fv.fieldId] = fv.value;
|
||
}
|
||
|
||
const assigneesByTask = new Map<number, Array<{ userId: number; user: { firstName: string | null; middleName: string | null; lastName: string | null; email: string } }>>();
|
||
for (const a of allTaskAssignees) {
|
||
if (!assigneesByTask.has(a.taskId)) {
|
||
assigneesByTask.set(a.taskId, []);
|
||
}
|
||
assigneesByTask.get(a.taskId)!.push({ userId: a.userId, user: { firstName: a.firstName, middleName: a.middleName, lastName: a.lastName, email: a.email } });
|
||
}
|
||
|
||
const tasksWithFields = allTasks.map(task => ({
|
||
...task,
|
||
fieldValues: fieldValuesByTask.get(task.id) || {},
|
||
assignees: assigneesByTask.get(task.id) || [],
|
||
}));
|
||
|
||
return { tasks: tasksWithFields, total };
|
||
}
|
||
|
||
async getTaskCountsByStatus(formId: number, organizationId: number): Promise<Array<{ statusId: number; name: string; color: string; count: number }>> {
|
||
const statuses = await db
|
||
.select()
|
||
.from(formStatuses)
|
||
.where(and(eq(formStatuses.formId, formId), eq(formStatuses.isFinal, false)))
|
||
.orderBy(asc(formStatuses.position));
|
||
|
||
if (statuses.length === 0) return [];
|
||
|
||
const countsResult = await db
|
||
.select({
|
||
currentStatusId: tasks.currentStatusId,
|
||
count: sql<number>`count(*)::int`,
|
||
})
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.formId, formId), eq(forms.organizationId, organizationId)))
|
||
.groupBy(tasks.currentStatusId);
|
||
|
||
const countMap = new Map(countsResult.map(c => [c.currentStatusId, c.count]));
|
||
|
||
return statuses.map(s => ({
|
||
statusId: s.id,
|
||
name: s.name,
|
||
color: s.color || '#94a3b8',
|
||
count: countMap.get(s.id) || 0,
|
||
}));
|
||
}
|
||
|
||
async getTasksByOrganization(
|
||
organizationId: number,
|
||
options?: {
|
||
limit?: number;
|
||
offset?: number;
|
||
sortBy?: 'updatedAt' | 'createdAt';
|
||
order?: 'asc' | 'desc';
|
||
statusId?: number;
|
||
assignedTo?: number;
|
||
minimal?: boolean;
|
||
excludeDescription?: boolean;
|
||
cursor?: TasksCursor | null;
|
||
}
|
||
): Promise<Task[] | Partial<Task>[]> {
|
||
const sortBy = options?.sortBy || 'updatedAt';
|
||
const order = options?.order || 'desc';
|
||
// Дефолт 50, жёсткий кап 200 — список никогда не выгружает таблицу целиком
|
||
const limit = clampTasksLimit(options?.limit);
|
||
const offset = options?.offset;
|
||
const statusId = options?.statusId;
|
||
const assignedTo = options?.assignedTo;
|
||
const minimal = options?.minimal || false;
|
||
|
||
const sortColumn = sortBy === 'updatedAt' ? tasks.updatedAt : tasks.createdAt;
|
||
const orderFn = order === 'desc' ? desc : asc;
|
||
|
||
const conditions = [eq(forms.organizationId, organizationId)];
|
||
|
||
if (statusId) {
|
||
conditions.push(eq(tasks.currentStatusId, statusId));
|
||
}
|
||
if (assignedTo) {
|
||
conditions.push(eq(tasks.assignedTo, assignedTo));
|
||
}
|
||
|
||
// Курсорная пагинация работает только для дефолтной сортировки (updatedAt DESC, id DESC)
|
||
const cursor = options?.cursor ?? null;
|
||
const useCursor = cursor !== null && sortBy === 'updatedAt' && order === 'desc';
|
||
if (useCursor) {
|
||
conditions.push(or(
|
||
lt(tasks.updatedAt, cursor.updatedAt),
|
||
and(eq(tasks.updatedAt, cursor.updatedAt), lt(tasks.id, cursor.id))
|
||
)!);
|
||
}
|
||
// В курсорном режиме добавляем id как детерминированный тай-брейкер
|
||
const orderByClause = useCursor ? [desc(tasks.updatedAt), desc(tasks.id)] : [orderFn(sortColumn)];
|
||
|
||
if (minimal) {
|
||
return await db
|
||
.select({
|
||
id: tasks.id,
|
||
formId: tasks.formId,
|
||
title: tasks.title,
|
||
currentStatusId: tasks.currentStatusId,
|
||
assignedTo: tasks.assignedTo,
|
||
updatedAt: tasks.updatedAt,
|
||
createdAt: tasks.createdAt,
|
||
status: formStatuses.name
|
||
})
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.leftJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
|
||
.where(and(...conditions))
|
||
.orderBy(...orderByClause)
|
||
.limit(limit)
|
||
.offset(offset || 0);
|
||
}
|
||
|
||
// Списковый ответ без description (тяжёлая text-колонка) — только по явному флагу;
|
||
// остальные caller'ы (embedding, MCP) получают полную строку, как раньше
|
||
if (options?.excludeDescription) {
|
||
const rows = await db
|
||
.select({
|
||
id: tasks.id,
|
||
organizationId: tasks.organizationId,
|
||
formId: tasks.formId,
|
||
title: tasks.title,
|
||
currentStatusId: tasks.currentStatusId,
|
||
assignedTo: tasks.assignedTo,
|
||
createdBy: tasks.createdBy,
|
||
createdAt: tasks.createdAt,
|
||
updatedAt: tasks.updatedAt,
|
||
completedAt: tasks.completedAt,
|
||
isCompleted: tasks.isCompleted,
|
||
dueDate: tasks.dueDate,
|
||
parentTaskId: tasks.parentTaskId,
|
||
depth: tasks.depth,
|
||
position: tasks.position,
|
||
})
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(...conditions))
|
||
.orderBy(...orderByClause)
|
||
.limit(limit)
|
||
.offset(offset || 0);
|
||
return rows as unknown as Task[];
|
||
}
|
||
|
||
const results = await db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(...conditions))
|
||
.orderBy(...orderByClause)
|
||
.limit(limit)
|
||
.offset(offset || 0);
|
||
|
||
return results.map((r) => r.tasks);
|
||
}
|
||
|
||
/** Точный COUNT(*) задач организации — для оценки прогресса без выгрузки строк */
|
||
async getTasksCountByOrganization(organizationId: number): Promise<number> {
|
||
const result = await db
|
||
.select({ count: sql<number>`count(*)` })
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(eq(forms.organizationId, organizationId));
|
||
return Number(result[0]?.count || 0);
|
||
}
|
||
|
||
async createTask(insertTask: InsertTask): Promise<Task> {
|
||
let depth = 0;
|
||
let position = 0;
|
||
|
||
if (insertTask.parentTaskId) {
|
||
const [parentTask] = await db
|
||
.select()
|
||
.from(tasks)
|
||
.where(eq(tasks.id, insertTask.parentTaskId));
|
||
|
||
if (parentTask) {
|
||
depth = (parentTask.depth || 0) + 1;
|
||
|
||
const siblings = await db
|
||
.select({ count: sql<number>`count(*)` })
|
||
.from(tasks)
|
||
.where(eq(tasks.parentTaskId, insertTask.parentTaskId));
|
||
|
||
position = Number(siblings[0]?.count || 0);
|
||
}
|
||
}
|
||
|
||
const [task] = await db
|
||
.insert(tasks)
|
||
.values({
|
||
...(insertTask as typeof tasks.$inferInsert),
|
||
depth,
|
||
position,
|
||
})
|
||
.returning();
|
||
// Создатель и ответственный получают доступ к новой задаче — сброс их кэша
|
||
if (task.createdBy) invalidateAccessibleTasksForUser(task.createdBy, task.organizationId);
|
||
if (task.assignedTo && task.assignedTo !== task.createdBy) {
|
||
invalidateAccessibleTasksForUser(task.assignedTo, task.organizationId);
|
||
}
|
||
return task;
|
||
}
|
||
|
||
async getSubtasks(parentTaskId: number, organizationId: number): Promise<Task[]> {
|
||
return await db
|
||
.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(
|
||
eq(tasks.parentTaskId, parentTaskId),
|
||
eq(forms.organizationId, organizationId)
|
||
))
|
||
.orderBy(tasks.position)
|
||
.then(results => results.map(r => r.tasks));
|
||
}
|
||
|
||
async getTaskTree(taskId: number, organizationId: number): Promise<any> {
|
||
const task = await this.getTask(taskId, organizationId);
|
||
if (!task) return null;
|
||
|
||
const subtasks = await this.getSubtasks(taskId, organizationId);
|
||
const subtasksWithChildren = await Promise.all(
|
||
subtasks.map(async (subtask) => {
|
||
return await this.getTaskTree(subtask.id, organizationId);
|
||
})
|
||
);
|
||
|
||
return {
|
||
...task,
|
||
subtasks: subtasksWithChildren.filter(Boolean)
|
||
};
|
||
}
|
||
|
||
async updateTask(id: number, organizationId: number, updates: Partial<Task>): Promise<Task> {
|
||
const [task] = await db
|
||
.update(tasks)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(and(
|
||
eq(tasks.id, id),
|
||
exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId))))
|
||
))
|
||
.returning();
|
||
// Переназначение ответственного даёт доступ новому исполнителю
|
||
if ('assignedTo' in updates && updates.assignedTo != null) {
|
||
invalidateAccessibleTasksForUser(updates.assignedTo, organizationId);
|
||
}
|
||
return task;
|
||
}
|
||
|
||
async updateTaskIfNotModified(
|
||
id: number,
|
||
organizationId: number,
|
||
updates: Partial<Task>,
|
||
requiredUpdatedAt: Date,
|
||
): Promise<Task | null> {
|
||
const [task] = await db
|
||
.update(tasks)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(and(
|
||
eq(tasks.id, id),
|
||
eq(tasks.updatedAt, requiredUpdatedAt),
|
||
exists(db.select().from(forms).where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId))))
|
||
))
|
||
.returning();
|
||
return task ?? null;
|
||
}
|
||
|
||
async deleteTask(id: number, organizationId: number): Promise<void> {
|
||
// Cleanup RAG embeddings for this task and its messages
|
||
const { deleteRagEmbeddingsByTask } = await import('../services/embedding.service.js');
|
||
await deleteRagEmbeddingsByTask(organizationId, id);
|
||
|
||
await db
|
||
.delete(tasks)
|
||
.where(and(
|
||
eq(tasks.id, id),
|
||
exists(
|
||
db.select()
|
||
.from(forms)
|
||
.where(and(eq(forms.id, tasks.formId), eq(forms.organizationId, organizationId)))
|
||
)
|
||
));
|
||
}
|
||
|
||
async searchTasksByKeyword(
|
||
organizationId: number,
|
||
query: string,
|
||
limit: number = 10
|
||
): Promise<(Pick<Task, 'id' | 'title' | 'description' | 'formId' | 'currentStatusId' | 'isCompleted'> & { statusName: string | null; statusColor: string | null; statusIsFinal: boolean | null })[]> {
|
||
const pattern = `%${query}%`;
|
||
const results = await db
|
||
.select({
|
||
id: tasks.id,
|
||
title: tasks.title,
|
||
description: tasks.description,
|
||
formId: tasks.formId,
|
||
currentStatusId: tasks.currentStatusId,
|
||
isCompleted: tasks.isCompleted,
|
||
statusName: formStatuses.name,
|
||
statusColor: formStatuses.color,
|
||
statusIsFinal: formStatuses.isFinal,
|
||
})
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.leftJoin(formStatuses, eq(tasks.currentStatusId, formStatuses.id))
|
||
.where(
|
||
and(
|
||
eq(forms.organizationId, organizationId),
|
||
or(
|
||
ilike(tasks.title, pattern),
|
||
ilike(tasks.description, pattern)
|
||
)
|
||
)
|
||
)
|
||
.orderBy(desc(tasks.updatedAt))
|
||
.limit(limit);
|
||
return results;
|
||
}
|
||
|
||
// Task Field Values
|
||
async getTaskFieldValues(taskId: number, organizationId: number): Promise<TaskFieldValue[]> {
|
||
return await db
|
||
.select()
|
||
.from(taskFieldValues)
|
||
.innerJoin(tasks, eq(taskFieldValues.taskId, tasks.id))
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(taskFieldValues.taskId, taskId), eq(forms.organizationId, organizationId)))
|
||
.orderBy(taskFieldValues.fieldId)
|
||
.then(results => results.map(r => r.task_field_values));
|
||
}
|
||
|
||
async createTaskFieldValue(insertValue: InsertTaskFieldValue): Promise<TaskFieldValue> {
|
||
const [value] = await db
|
||
.insert(taskFieldValues)
|
||
.values(insertValue)
|
||
.returning();
|
||
return value;
|
||
}
|
||
|
||
async updateTaskFieldValue(taskId: number, fieldId: number, organizationId: number, updates: Partial<TaskFieldValue>): Promise<TaskFieldValue> {
|
||
const [value] = await db
|
||
.update(taskFieldValues)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(and(
|
||
eq(taskFieldValues.taskId, taskId),
|
||
eq(taskFieldValues.fieldId, fieldId),
|
||
exists(
|
||
db.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.id, taskId), eq(forms.organizationId, organizationId)))
|
||
)
|
||
))
|
||
.returning();
|
||
return value;
|
||
}
|
||
|
||
async bulkUpsertTaskFieldValues(taskId: number, formId: number, updates: Array<{ fieldId: number; value: unknown }>): Promise<TaskFieldValue[]> {
|
||
const results: TaskFieldValue[] = [];
|
||
await db.transaction(async (tx) => {
|
||
const existing = await tx
|
||
.select()
|
||
.from(taskFieldValues)
|
||
.where(eq(taskFieldValues.taskId, taskId));
|
||
const existingMap = new Map(existing.map(v => [v.fieldId, v]));
|
||
for (const { fieldId, value } of updates) {
|
||
const existingValue = existingMap.get(fieldId);
|
||
if (existingValue) {
|
||
const [updated] = await tx
|
||
.update(taskFieldValues)
|
||
.set({ value, updatedAt: new Date() })
|
||
.where(and(eq(taskFieldValues.taskId, taskId), eq(taskFieldValues.fieldId, fieldId)))
|
||
.returning();
|
||
results.push(updated);
|
||
} else {
|
||
const [inserted] = await tx
|
||
.insert(taskFieldValues)
|
||
.values({ taskId, fieldId, formId, value })
|
||
.returning();
|
||
results.push(inserted);
|
||
}
|
||
}
|
||
});
|
||
return results;
|
||
}
|
||
|
||
async deleteTaskFieldValue(taskId: number, fieldId: number, organizationId: number): Promise<void> {
|
||
await db
|
||
.delete(taskFieldValues)
|
||
.where(and(
|
||
eq(taskFieldValues.taskId, taskId),
|
||
eq(taskFieldValues.fieldId, fieldId),
|
||
exists(
|
||
db.select()
|
||
.from(tasks)
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(eq(tasks.id, taskId), eq(forms.organizationId, organizationId)))
|
||
)
|
||
));
|
||
}
|
||
|
||
// Field History
|
||
async getFieldHistory(taskId: number, fieldId: number, organizationId: number): Promise<FieldHistory[]> {
|
||
return await db
|
||
.select()
|
||
.from(fieldHistory)
|
||
.innerJoin(tasks, eq(fieldHistory.taskId, tasks.id))
|
||
.innerJoin(forms, eq(tasks.formId, forms.id))
|
||
.where(and(
|
||
eq(fieldHistory.taskId, taskId),
|
||
eq(fieldHistory.fieldId, fieldId),
|
||
eq(forms.organizationId, organizationId)
|
||
))
|
||
.orderBy(desc(fieldHistory.changedAt))
|
||
.then(results => results.map(r => r.field_history));
|
||
}
|
||
|
||
async createFieldHistory(insertHistory: InsertFieldHistory): Promise<FieldHistory> {
|
||
const [history] = await db
|
||
.insert(fieldHistory)
|
||
.values(insertHistory)
|
||
.returning();
|
||
return history;
|
||
}
|
||
}
|