- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
482 lines
16 KiB
TypeScript
482 lines
16 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 } from "drizzle-orm";
|
|
import { formatUserName } from "../utils/formatUserName";
|
|
import { FormsStorage } from "./forms.storage";
|
|
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 }
|
|
): 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));
|
|
}
|
|
|
|
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;
|
|
}
|
|
): Promise<Task[] | Partial<Task>[]> {
|
|
const sortBy = options?.sortBy || 'updatedAt';
|
|
const order = options?.order || 'desc';
|
|
const limit = 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));
|
|
}
|
|
|
|
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(orderFn(sortColumn))
|
|
.limit(limit || 1000)
|
|
.offset(offset || 0);
|
|
}
|
|
|
|
const results = await db
|
|
.select()
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.id))
|
|
.where(and(...conditions))
|
|
.orderBy(orderFn(sortColumn))
|
|
.limit(limit || 10000)
|
|
.offset(offset || 0);
|
|
|
|
return results.map((r) => r.tasks);
|
|
}
|
|
|
|
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();
|
|
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();
|
|
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'>[]> {
|
|
const pattern = `%${query}%`;
|
|
const results = await db
|
|
.select({
|
|
id: tasks.id,
|
|
title: tasks.title,
|
|
description: tasks.description,
|
|
formId: tasks.formId,
|
|
currentStatusId: tasks.currentStatusId,
|
|
})
|
|
.from(tasks)
|
|
.innerJoin(forms, eq(tasks.formId, forms.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;
|
|
}
|
|
}
|