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

638 lines
24 KiB
TypeScript

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";
import { eq, and, desc, exists, sql, gt } 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: User[];
hasRelations: boolean;
assignees: Array<TaskAssignee & { user: { id: number; firstName: string | null; lastName: string | null; email: string } }>;
} | 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] = 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),
]);
const usersWithFullName = orgUsers.map((u: User) => ({
...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,
};
}
// Task Messages (Chat)
async getTaskMessages(taskId: number, organizationId: number, afterId?: 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)] : [])
))
.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,
};
}
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`,
},
});
}
}