Files
iistwin/server/storage/tasks-core.storage.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

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;
}
}