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; } | 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(); for (const f of fields) { if (f.type === 'table') { const tid = (f.options as Record | undefined)?.tableId; if (tid) tableIds.add(tid); } } const tableMap = new Map; 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>(); const tableRowCache = new Map>(); const roleMembersCache = new Map>(); const getCachedTableRows = (tableId: number): Promise => { if (!tableRowsCache.has(tableId)) { tableRowsCache.set(tableId, _this.getDataTableRows(tableId, organizationId)); } return tableRowsCache.get(tableId)!; }; const getCachedTableRow = (rowId: number, tableId: number): Promise => { 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(); // 1. Simple assigneeUserId if (tr.assigneeUserId) { resolvedUserIds.add(tr.assigneeUserId); } // 2. New format: assignees[] const conditions = tr.assigneeConditions as Record | 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[]) { 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)?.tableId) { const tableId = (field.options as Record).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 { 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 { 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 { 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): Promise { 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 { // 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 { 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 { 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 { 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 { 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> { 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 { await db .insert(taskViews) .values({ taskId, userId, organizationId, }) .onConflictDoUpdate({ target: [taskViews.organizationId, taskViews.userId, taskViews.taskId], set: { viewedAt: sql`CURRENT_TIMESTAMP`, }, }); } }