Files
iistwin/server/storage/tasks.storage.ts

676 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { users, forms, tasks, taskMessages, messageReads, bots, roles, taskViews, type SafeUser, 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";
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";
import { resolveTaskFieldTitles } from "../routes/shared";
export class TasksStorage extends TasksCoreStorage {
async getTaskDetail(id: number, organizationId: number): Promise<{
task: Task;
form: Form;
fields: FormField[];
statuses: FormStatus[];
tabs: FormTab[];
transitions: (StatusTransition & { resolvedAssigneeUserIds?: number[] })[];
fieldValues: TaskFieldValue[];
subtasks: Task[];
messages: TaskMessageWithAuthor[];
users: Array<SafeUser & { fullName: string }>;
hasRelations: boolean;
assignees: Array<TaskAssignee & { user: { id: number; firstName: string | null; lastName: string | null; email: string } }>;
/** Удалённые (мягко) поля формы со значениями в fieldValues — блок «Удалённые поля» */
deletedFields: FormField[];
} | null> {
const taskResult = await db
.select()
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(eq(tasks.id, id), eq(forms.organizationId, organizationId)));
const [result] = taskResult;
if (!result) return null;
const task = result.tasks;
const form = result.forms;
const [fields, statuses, tabs, transitions, fieldValues, subtasks, messages, orgUsers, orgRoles, relationsCheck, assignees, deletedFields] = await Promise.all([
this.getFormFields(form.id, organizationId),
this.getFormStatuses(form.id, organizationId),
this.getFormTabs(form.id, organizationId),
this.getStatusTransitions(form.id, organizationId),
this.getTaskFieldValues(task.id, organizationId),
this.getSubtasks(task.id, organizationId),
this.getTaskMessages(task.id, organizationId),
this.getUsersByOrganization(organizationId),
db.select({ id: roles.id, name: roles.name }).from(roles).where(eq(roles.organizationId, organizationId)),
db.select({ id: taskRelations.id }).from(taskRelations)
.where(and(
eq(taskRelations.organizationId, organizationId),
sql`(${taskRelations.parentTaskId} = ${task.id} OR ${taskRelations.childTaskId} = ${task.id})`,
))
.limit(1),
this.getTaskAssignees(task.id, organizationId),
this.getDeletedFormFields(form.id, organizationId),
]);
const usersWithFullName = orgUsers.map((u: SafeUser) => ({
...u,
fullName: formatUserName(u)
}));
// Build maps for resolving display text of task/table fields in conditions
const valueByFieldId = new Map(fieldValues.map((v: TaskFieldValue) => [v.fieldId, v.value]));
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, fields, organizationId);
const tableIds = new Set<number>();
for (const f of fields) {
if (f.type === 'table') {
const tid = (f.options as Record<string, number> | undefined)?.tableId;
if (tid) tableIds.add(tid);
}
}
const tableMap = new Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>();
const _this = this as any;
for (const tableId of tableIds) {
const fullTable = await _this.getDataTableFull(tableId, organizationId);
if (fullTable) {
tableMap.set(tableId, {
rows: fullTable.rows as Array<{ id: number; values: string[] }>,
columns: fullTable.columns as Array<{ name: string }>,
});
}
}
const conditionContext = {
users: usersWithFullName,
roles: orgRoles,
taskTitleMap,
tableMap,
};
// Per-call caches to avoid duplicate DB queries across transitions
const tableRowsCache = new Map<number, Promise<DataTableRow[]>>();
const tableRowCache = new Map<string, Promise<DataTableRow | undefined>>();
const roleMembersCache = new Map<number, Promise<{ id: number; userId: number; createdAt: Date | null }[]>>();
const getCachedTableRows = (tableId: number): Promise<DataTableRow[]> => {
if (!tableRowsCache.has(tableId)) {
tableRowsCache.set(tableId, _this.getDataTableRows(tableId, organizationId));
}
return tableRowsCache.get(tableId)!;
};
const getCachedTableRow = (rowId: number, tableId: number): Promise<DataTableRow | undefined> => {
const key = `${tableId}:${rowId}`;
if (!tableRowCache.has(key)) {
tableRowCache.set(key, _this.getDataTableRow(rowId, tableId, organizationId));
}
return tableRowCache.get(key)!;
};
const getCachedRoleMembers = (roleId: number): Promise<{ id: number; userId: number; createdAt: Date | null }[]> => {
if (!roleMembersCache.has(roleId)) {
roleMembersCache.set(roleId, _this.getRoleMembersByRole(roleId));
}
return roleMembersCache.get(roleId)!;
};
// Compute resolvedAssigneeUserIds for transitions with dynamic assigneeConditions
const resolvedTransitions = await Promise.all(transitions.map(async (tr: StatusTransition) => {
const resolvedUserIds = new Set<number>();
// 1. Simple assigneeUserId
if (tr.assigneeUserId) {
resolvedUserIds.add(tr.assigneeUserId);
}
// 2. New format: assignees[]
const conditions = tr.assigneeConditions as Record<string, unknown> | null;
if (conditions?.assignees && Array.isArray(conditions.assignees)) {
for (const assignee of conditions.assignees) {
if ((assignee.type === 'user' || assignee.type === 'role') && !assignee.id) continue;
if ((assignee.type === 'field_user' || assignee.type === 'table_column_user') && !assignee.fieldCode) continue;
const rulesMatch = fieldConditionsMatch(assignee.rules || [], fieldValues, fields, conditionContext);
if (!rulesMatch) continue;
if (assignee.type === 'user') {
resolvedUserIds.add(assignee.id as number);
} else if (assignee.type === 'role') {
const members = await getCachedRoleMembers(assignee.id as number);
members.forEach((m: any) => resolvedUserIds.add(m.userId));
} else if (assignee.type === 'field_user') {
let userId: number | undefined;
if (assignee.fieldCode === '_assignee') {
userId = task.assignedTo ?? undefined;
} else if (assignee.fieldCode === '_author') {
userId = task.createdBy ?? undefined;
} else {
const field = fields.find(f => f.code === assignee.fieldCode);
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
userId = fieldValue?.value ? parseInt(String(fieldValue.value)) : undefined;
}
if (userId && userId > 0) {
resolvedUserIds.add(userId);
}
} else if (assignee.type === 'table_column_user') {
const field = fields.find(f => f.code === assignee.fieldCode);
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN;
if (!isNaN(rowId) && rowId > 0) {
const tableId = (field as any)?.options?.tableId as number;
if (tableId) {
const row = await getCachedTableRow(rowId, tableId);
if (row) {
const userId = parseInt(String((row.values as unknown[])[assignee.columnIndex as number] ?? ''));
if (!isNaN(userId) && userId > 0) {
resolvedUserIds.add(userId);
}
}
}
}
}
}
}
// 3. Old format: conditions.rules
if (conditions?.rules && Array.isArray(conditions.rules)) {
for (const rule of conditions.rules as Record<string, unknown>[]) {
const field = fields.find(f => f.code === String(rule.fieldCode ?? ''));
if (!field) continue;
const fieldValue = fieldValues.find(fv => fv.fieldId === field.id);
if (rule.assigneeSource === 'role' && rule.roleId !== undefined) {
const ruleVal = String(rule.value ?? '');
const displayText = resolveFieldDisplayText(fieldValue?.value, field, conditionContext);
const fieldMatches = !ruleVal || displayText.toLowerCase().includes(ruleVal.toLowerCase());
if (fieldMatches) {
const members = await getCachedRoleMembers(rule.roleId as number);
members.forEach(m => resolvedUserIds.add(m.userId));
}
} else if (rule.tableId !== undefined && rule.tableUserColumnIndex !== undefined) {
const taskVal = String(fieldValue?.value ?? '');
if (!taskVal) continue;
const rows = await getCachedTableRows(rule.tableId as number);
const keyIdx = rule.tableKeyColumnIndex as number;
const userIdx = rule.tableUserColumnIndex as number;
if (rule.value !== undefined && taskVal !== String(rule.value)) continue;
const matchRow = rows.find(r => String((r.values as unknown[])[keyIdx] ?? '') === taskVal);
if (!matchRow) continue;
const userId = parseInt(String((matchRow.values as unknown[])[userIdx] ?? ''));
if (!isNaN(userId)) resolvedUserIds.add(userId);
} else if (rule.tableUserColumnIndex !== undefined && (field.options as Record<string, unknown>)?.tableId) {
const tableId = (field.options as Record<string, number>).tableId;
const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN;
if (isNaN(rowId)) continue;
const row = await getCachedTableRow(rowId, tableId);
if (!row) continue;
const userId = parseInt(String((row.values as unknown[])[rule.tableUserColumnIndex as number] ?? ''));
if (!isNaN(userId)) resolvedUserIds.add(userId);
} else if (rule.assigneeUserId !== undefined) {
const ruleVal = String(rule.value ?? '');
const displayText = resolveFieldDisplayText(fieldValue?.value, field, conditionContext);
if (ruleVal && displayText.toLowerCase().includes(ruleVal.toLowerCase())) {
resolvedUserIds.add(rule.assigneeUserId as number);
}
}
}
}
if (resolvedUserIds.size === 0 && conditions?.fallbackAssigneeUserId) {
resolvedUserIds.add(conditions.fallbackAssigneeUserId as number);
}
return { ...tr, resolvedAssigneeUserIds: Array.from(resolvedUserIds) };
}));
return {
task,
form,
fields,
statuses,
tabs,
transitions: resolvedTransitions,
fieldValues,
subtasks,
messages,
users: usersWithFullName,
hasRelations: relationsCheck.length > 0,
assignees,
deletedFields,
};
}
// Task Messages (Chat)
async getTaskMessages(taskId: number, organizationId: number, afterId?: number, viewerUserId?: number): Promise<TaskMessageWithAuthor[]> {
const replyToMsg = db.select({
id: taskMessages.id,
message: taskMessages.message,
taskId: taskMessages.taskId,
authorId: taskMessages.authorId,
}).from(taskMessages).as('reply_to_msg');
const replyToAuthor = db.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
}).from(users).as('reply_to_author');
const results = await db
.select({
message: taskMessages,
author: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
},
bot: {
id: bots.id,
name: bots.name,
avatarUrl: bots.avatarUrl,
},
replyToMessage: {
id: replyToMsg.id,
message: replyToMsg.message,
},
replyToAuthor: {
firstName: replyToAuthor.firstName,
lastName: replyToAuthor.lastName,
middleName: replyToAuthor.middleName,
}
})
.from(taskMessages)
.leftJoin(users, eq(taskMessages.authorId, users.id))
.leftJoin(bots, eq(taskMessages.botId, bots.id))
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.leftJoin(replyToMsg, and(
eq(taskMessages.replyToMessageId, replyToMsg.id),
eq(replyToMsg.taskId, taskMessages.taskId)
))
.leftJoin(replyToAuthor, eq(replyToMsg.authorId, replyToAuthor.id))
.where(and(
eq(taskMessages.taskId, taskId),
eq(forms.organizationId, organizationId),
...(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);
return results.map(r => ({
...r.message,
author: r.author?.id ? {
id: r.author.id,
firstName: r.author.firstName,
lastName: r.author.lastName,
middleName: r.author.middleName,
} : { id: 0, firstName: 'Система', lastName: '', middleName: '' },
bot: r.bot?.id ? {
id: r.bot.id,
name: r.bot.name,
avatarUrl: r.bot.avatarUrl,
} : undefined,
replyToMessage: r.replyToMessage && r.replyToMessage.id ? {
id: r.replyToMessage.id,
message: r.replyToMessage.message,
author: r.replyToAuthor ? {
firstName: r.replyToAuthor.firstName,
lastName: r.replyToAuthor.lastName,
} : undefined
} : undefined,
}));
}
async getTaskMessage(messageId: number, organizationId: number): Promise<TaskMessageWithAuthor | undefined> {
const replyToMsg = db.select({
id: taskMessages.id,
message: taskMessages.message,
taskId: taskMessages.taskId,
authorId: taskMessages.authorId,
}).from(taskMessages).as('reply_to_msg');
const replyToAuthor = db.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
}).from(users).as('reply_to_author');
const results = await db
.select({
message: taskMessages,
author: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
},
bot: {
id: bots.id,
name: bots.name,
avatarUrl: bots.avatarUrl,
},
replyToMessage: {
id: replyToMsg.id,
message: replyToMsg.message,
},
replyToAuthor: {
firstName: replyToAuthor.firstName,
lastName: replyToAuthor.lastName,
}
})
.from(taskMessages)
.leftJoin(users, eq(taskMessages.authorId, users.id))
.leftJoin(bots, eq(taskMessages.botId, bots.id))
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.leftJoin(replyToMsg, and(
eq(taskMessages.replyToMessageId, replyToMsg.id),
eq(replyToMsg.taskId, taskMessages.taskId)
))
.leftJoin(replyToAuthor, eq(replyToMsg.authorId, replyToAuthor.id))
.where(and(
eq(taskMessages.id, messageId),
eq(forms.organizationId, organizationId)
))
.limit(1);
if (results.length === 0) return undefined;
const r = results[0];
return {
...r.message,
author: r.author?.id ? {
id: r.author.id,
firstName: r.author.firstName,
lastName: r.author.lastName,
middleName: r.author.middleName,
} : { id: 0, firstName: 'Система', lastName: '', middleName: '' },
bot: r.bot?.id ? {
id: r.bot.id,
name: r.bot.name,
avatarUrl: r.bot.avatarUrl,
} : undefined,
replyToMessage: r.replyToMessage && r.replyToMessage.id ? {
id: r.replyToMessage.id,
message: r.replyToMessage.message,
author: r.replyToAuthor ? {
firstName: r.replyToAuthor.firstName,
lastName: r.replyToAuthor.lastName,
} : undefined
} : undefined,
};
}
/** Батч-загрузка сообщений по списку id одним IN-запросом (сырые строки, tenant-фильтр) */
async getTaskMessagesByIds(messageIds: number[], organizationId: number): Promise<TaskMessage[]> {
if (messageIds.length === 0) return [];
return await db
.select()
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(taskMessages.id, messageIds), eq(forms.organizationId, organizationId)))
.orderBy(taskMessages.createdAt)
.then(results => results.map(r => r.task_messages));
}
/** Батч-загрузка сообщений для списка задач одним IN-запросом (сырые строки, tenant-фильтр) */
async getTaskMessagesByTaskIds(taskIds: number[], organizationId: number): Promise<TaskMessage[]> {
if (taskIds.length === 0) return [];
return await db
.select()
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(inArray(taskMessages.taskId, taskIds), eq(forms.organizationId, organizationId)))
.orderBy(taskMessages.createdAt)
.then(results => results.map(r => r.task_messages));
}
async createTaskMessage(insertMessage: any, organizationId: number): Promise<any> {
const taskExists = await db
.select({ id: tasks.id })
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(
eq(tasks.id, insertMessage.taskId),
eq(forms.organizationId, organizationId)
))
.limit(1);
if (taskExists.length === 0) {
throw new Error('Task not found or does not belong to organization');
}
const [createdMessage] = await db
.insert(taskMessages)
.values(insertMessage)
.returning();
const replyToMsg = db.select({
id: taskMessages.id,
message: taskMessages.message,
taskId: taskMessages.taskId,
authorId: taskMessages.authorId,
}).from(taskMessages).as('reply_to_msg');
const replyToAuthor = db.select({
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
}).from(users).as('reply_to_author');
const [messageWithDetails] = await db
.select({
message: taskMessages,
author: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
},
bot: {
id: bots.id,
name: bots.name,
avatarUrl: bots.avatarUrl,
},
replyToMessage: {
id: replyToMsg.id,
message: replyToMsg.message,
},
replyToAuthor: {
firstName: replyToAuthor.firstName,
lastName: replyToAuthor.lastName,
},
})
.from(taskMessages)
.leftJoin(users, eq(taskMessages.authorId, users.id))
.leftJoin(bots, eq(taskMessages.botId, bots.id))
.leftJoin(replyToMsg, eq(taskMessages.replyToMessageId, replyToMsg.id))
.leftJoin(replyToAuthor, eq(replyToMsg.authorId, replyToAuthor.id))
.where(eq(taskMessages.id, createdMessage.id))
.limit(1);
return {
...messageWithDetails.message,
author: messageWithDetails.author ? {
id: messageWithDetails.author.id,
firstName: messageWithDetails.author.firstName,
lastName: messageWithDetails.author.lastName,
} : undefined,
bot: messageWithDetails.bot ? {
id: messageWithDetails.bot.id,
name: messageWithDetails.bot.name,
avatarUrl: messageWithDetails.bot.avatarUrl,
} : undefined,
replyToMessage: messageWithDetails.replyToMessage && messageWithDetails.replyToMessage.id ? {
id: messageWithDetails.replyToMessage.id,
message: messageWithDetails.replyToMessage.message,
author: messageWithDetails.replyToAuthor ? {
firstName: messageWithDetails.replyToAuthor.firstName,
lastName: messageWithDetails.replyToAuthor.lastName,
} : undefined
} : undefined,
};
}
async updateTaskMessage(id: number, organizationId: number, updates: Partial<TaskMessage>): Promise<TaskMessage> {
const [message] = await db
.update(taskMessages)
.set(updates)
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(
eq(taskMessages.id, id),
eq(taskMessages.taskId, tasks.id),
eq(forms.organizationId, organizationId)
))
.returning();
return message;
}
async deleteTaskMessage(id: number, organizationId: number): Promise<void> {
// Cleanup RAG embedding for this message
const { deleteRagEmbedding } = await import('../services/embedding.service.js');
await deleteRagEmbedding(organizationId, 'task_message', id);
await db
.delete(taskMessages)
.where(and(
eq(taskMessages.id, id),
exists(
db.select()
.from(tasks)
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(eq(tasks.id, taskMessages.taskId), eq(forms.organizationId, organizationId)))
)
));
}
// Message Reads (Read Receipts)
async markMessageAsRead(messageId: number, userId: number, organizationId: number): Promise<MessageRead> {
const messageExists = await db
.select({ id: taskMessages.id })
.from(taskMessages)
.innerJoin(tasks, eq(taskMessages.taskId, tasks.id))
.innerJoin(forms, eq(tasks.formId, forms.id))
.where(and(
eq(taskMessages.id, messageId),
eq(forms.organizationId, organizationId)
))
.limit(1);
if (messageExists.length === 0) {
throw new Error('Message not found or does not belong to organization');
}
const [messageRead] = await db
.insert(messageReads)
.values({
messageId,
userId,
organizationId
})
.onConflictDoUpdate({
target: [messageReads.messageId, messageReads.userId],
set: {
readAt: sql`CURRENT_TIMESTAMP`
}
})
.returning();
return messageRead;
}
async getMessageReads(messageId: number, organizationId: number): Promise<MessageRead[]> {
return await db
.select()
.from(messageReads)
.where(and(
eq(messageReads.messageId, messageId),
eq(messageReads.organizationId, organizationId)
))
.orderBy(messageReads.readAt);
}
async getMessageReadsByUser(userId: number, organizationId: number): Promise<MessageRead[]> {
return await db
.select()
.from(messageReads)
.where(and(
eq(messageReads.userId, userId),
eq(messageReads.organizationId, organizationId)
))
.orderBy(desc(messageReads.readAt));
}
async isMessageReadByUser(messageId: number, userId: number, organizationId: number): Promise<boolean> {
const reads = await db
.select({ id: messageReads.id })
.from(messageReads)
.where(and(
eq(messageReads.messageId, messageId),
eq(messageReads.userId, userId),
eq(messageReads.organizationId, organizationId)
))
.limit(1);
return reads.length > 0;
}
// =====================
// Task Assignees (multiple users)
// =====================
async getTaskAssignees(taskId: number, organizationId: number): Promise<Array<TaskAssignee & { user: { id: number; firstName: string | null; lastName: string | null; middleName: string | null; email: string } }>> {
const rows = await db
.select({
id: taskAssignees.id,
taskId: taskAssignees.taskId,
userId: taskAssignees.userId,
organizationId: taskAssignees.organizationId,
delegatedFromUserId: taskAssignees.delegatedFromUserId,
pendingReview: taskAssignees.pendingReview,
createdAt: taskAssignees.createdAt,
user: {
id: users.id,
firstName: users.firstName,
lastName: users.lastName,
middleName: users.middleName,
email: users.email,
},
})
.from(taskAssignees)
.innerJoin(users, eq(taskAssignees.userId, users.id))
.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`,
},
});
}
}