Шаг 0.8 плана production-готовности: - shared/schema.ts: тип SafeUser + safeUserColumns - getUsersByOrganization, listUsers, searchUsers, getUsersByRole, getUsersByOrgRoleId, documents getUser — без чувствительных колонок - sync (initial/delta), автоматизации ctx.users.list, MCP list_users — sanitized
638 lines
24 KiB
TypeScript
638 lines
24 KiB
TypeScript
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 } 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 } }>;
|
|
} | 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: 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,
|
|
};
|
|
}
|
|
|
|
// 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`,
|
|
},
|
|
});
|
|
}
|
|
}
|