feat(documents): папки шаблонов, маппинги и поле document_template; fix(batch): атомарное создание задач, idempotency и массовое удаление в реестре
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { db } from "../db";
|
||||
import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows } from "@shared/schema";
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { tasks, taskFieldValues, users, organizations, forms, formFields, formTabs, regularTableRows, taskRelations } from "@shared/schema";
|
||||
import { eq, and, asc, sql } from "drizzle-orm";
|
||||
import type { TemplateVariable } from "@shared/schema";
|
||||
import { TransformService, type TransformConfig, type TransformType } from "./transform.service";
|
||||
import * as storage from "../storage";
|
||||
@@ -110,6 +110,10 @@ export class DataResolutionService {
|
||||
tabId?: number;
|
||||
transform?: TransformConfig | TransformType | string;
|
||||
locale?: string;
|
||||
relationType?: 'task_field' | 'parent' | 'child' | 'contract_legal_entity';
|
||||
sourceFieldCode?: string;
|
||||
targetFormId?: number;
|
||||
targetFieldCode?: string;
|
||||
};
|
||||
|
||||
let rawValue: string | null = null;
|
||||
@@ -149,8 +153,12 @@ export class DataResolutionService {
|
||||
break;
|
||||
}
|
||||
case 'related_task_field': {
|
||||
// TODO: реализовать при необходимости
|
||||
rawValue = null;
|
||||
rawValue = await this.resolveRelatedTaskField(
|
||||
variable,
|
||||
mapping,
|
||||
context,
|
||||
organizationId,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'task_table_tab': {
|
||||
@@ -327,4 +335,108 @@ export class DataResolutionService {
|
||||
if (!asset) return null;
|
||||
return `__ASSET__:${asset.fileKey}`;
|
||||
}
|
||||
|
||||
private async resolveRelatedTaskField(
|
||||
variable: TemplateVariable,
|
||||
mapping: Record<string, any>,
|
||||
context: ResolutionContext,
|
||||
organizationId: number,
|
||||
): Promise<string | null> {
|
||||
const relationType = mapping.relationType || 'task_field';
|
||||
const targetFormId = mapping.targetFormId ? Number(mapping.targetFormId) : undefined;
|
||||
const targetFieldCode = mapping.targetFieldCode as string | undefined;
|
||||
|
||||
if (!targetFieldCode) return null;
|
||||
|
||||
const relatedTaskId = await this.getRelatedTaskId(
|
||||
relationType,
|
||||
mapping.sourceFieldCode as string | undefined,
|
||||
context,
|
||||
organizationId,
|
||||
targetFormId,
|
||||
);
|
||||
|
||||
if (!relatedTaskId) return null;
|
||||
|
||||
const relatedFieldValues = await this.getRelatedTaskFieldValues(relatedTaskId);
|
||||
const fv = relatedFieldValues.find((f) => f.code === targetFieldCode);
|
||||
if (!fv) return null;
|
||||
|
||||
return this.extractFieldDisplayValue(fv.value, fv.type);
|
||||
}
|
||||
|
||||
private async getRelatedTaskId(
|
||||
relationType: string,
|
||||
sourceFieldCode: string | undefined,
|
||||
context: ResolutionContext,
|
||||
organizationId: number,
|
||||
targetFormId?: number,
|
||||
): Promise<number | null> {
|
||||
switch (relationType) {
|
||||
case 'task_field': {
|
||||
if (!sourceFieldCode) return null;
|
||||
const fv = context.fieldValues.find((f) => f.code === sourceFieldCode);
|
||||
if (!fv) return null;
|
||||
const value = fv.value;
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
// task-поле хранит ID связанной задачи числом
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'contract_legal_entity': {
|
||||
// Ищем поле типа contract-number и берём legalEntityTaskId
|
||||
const contractFv = context.fieldValues.find((f) => f.type === 'contract-number');
|
||||
if (!contractFv || !contractFv.value) return null;
|
||||
const obj = typeof contractFv.value === 'string' ? JSON.parse(contractFv.value) : contractFv.value;
|
||||
const legalEntityTaskId = obj?.legalEntityTaskId;
|
||||
if (legalEntityTaskId && typeof legalEntityTaskId === 'number') return legalEntityTaskId;
|
||||
return null;
|
||||
}
|
||||
case 'parent':
|
||||
case 'child': {
|
||||
const relations = await db
|
||||
.select()
|
||||
.from(taskRelations)
|
||||
.where(and(
|
||||
eq(taskRelations.organizationId, organizationId),
|
||||
relationType === 'parent'
|
||||
? eq(taskRelations.childTaskId, context.task.id)
|
||||
: eq(taskRelations.parentTaskId, context.task.id),
|
||||
));
|
||||
|
||||
if (relations.length === 0) return null;
|
||||
|
||||
const candidateIds = relations.map((r) =>
|
||||
relationType === 'parent' ? r.parentTaskId : r.childTaskId,
|
||||
);
|
||||
|
||||
if (!targetFormId) return candidateIds[0] || null;
|
||||
|
||||
for (const id of candidateIds) {
|
||||
const [relatedTask] = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
|
||||
if (relatedTask && relatedTask.formId === targetFormId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getRelatedTaskFieldValues(taskId: number): Promise<Array<{ code: string; value: unknown; type: string; name: string }>> {
|
||||
return db
|
||||
.select({
|
||||
code: formFields.code,
|
||||
value: taskFieldValues.value,
|
||||
type: formFields.type,
|
||||
name: formFields.name,
|
||||
})
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId))
|
||||
.where(eq(taskFieldValues.taskId, taskId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ export class DOCXConverterService {
|
||||
lang: options.lang || 'ru-RU',
|
||||
};
|
||||
|
||||
const result = await HTMLtoDOCX(html, null, opts);
|
||||
const convertFn = typeof HTMLtoDOCX === 'function' ? HTMLtoDOCX : (HTMLtoDOCX as any).default;
|
||||
if (typeof convertFn !== 'function') {
|
||||
throw new Error('HTMLtoDOCX converter is not available');
|
||||
}
|
||||
const result = await convertFn(html, null, opts);
|
||||
if (result instanceof ArrayBuffer) {
|
||||
return Buffer.from(result);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { uploadToS3, uploadToS3FromFile, downloadFromS3, isS3Enabled } from "../utils/s3";
|
||||
import { uploadsDir } from "../utils/upload";
|
||||
import { db } from "../db";
|
||||
import { fileUploads } from "@shared/schema";
|
||||
import { fileUploads, formFields, forms, tasks, taskFieldValues } from "@shared/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DocumentTemplateService } from "./template.service";
|
||||
import { DataResolutionService } from "./data-resolution.service";
|
||||
import { AssetService } from "./asset.service";
|
||||
@@ -55,6 +56,11 @@ interface AssetPlacement {
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface FileKeyInfo {
|
||||
key: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export class DocumentGenerationService {
|
||||
private pdfConverter = new PDFConverterService();
|
||||
private docxConverter = new DOCXConverterService();
|
||||
@@ -96,7 +102,7 @@ export class DocumentGenerationService {
|
||||
request.userId,
|
||||
);
|
||||
|
||||
const fileKeys: { pdf?: string; docx?: string } = {};
|
||||
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
|
||||
|
||||
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
||||
const pdfBuffer = await this.pdfConverter.convert(html, {
|
||||
@@ -115,7 +121,7 @@ export class DocumentGenerationService {
|
||||
`${template.name || 'document'}-${generation.id}.pdf`,
|
||||
'application/pdf',
|
||||
);
|
||||
fileKeys.pdf = key;
|
||||
fileKeys.pdf = { key, size: pdfBuffer.length };
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
||||
}
|
||||
|
||||
@@ -129,7 +135,7 @@ export class DocumentGenerationService {
|
||||
`${template.name || 'document'}-${generation.id}.docx`,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
fileKeys.docx = key;
|
||||
fileKeys.docx = { key, size: docxBuffer.length };
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxBuffer.length, request);
|
||||
}
|
||||
|
||||
@@ -142,19 +148,20 @@ export class DocumentGenerationService {
|
||||
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'completed',
|
||||
pdfFileKey: fileKeys.pdf || null,
|
||||
docxFileKey: fileKeys.docx || null,
|
||||
pdfFileKey: fileKeys.pdf?.key || null,
|
||||
docxFileKey: fileKeys.docx?.key || null,
|
||||
substitutedData: resolved,
|
||||
unresolvedVariables: unresolvedVariables.length > 0 ? unresolvedVariables : null,
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
||||
await this.saveToTargetField(template, request, fileKeys);
|
||||
|
||||
return {
|
||||
generationId: generation.id,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
|
||||
unresolvedVariables,
|
||||
status: 'completed',
|
||||
};
|
||||
@@ -248,7 +255,7 @@ export class DocumentGenerationService {
|
||||
await fs.copyFile(sourcePath, filledPath);
|
||||
}
|
||||
|
||||
const fileKeys: { pdf?: string; docx?: string } = {};
|
||||
const fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo } = {};
|
||||
|
||||
if (request.outputFormat === 'docx' || request.outputFormat === 'both') {
|
||||
const { key } = await uploadToS3FromFile(
|
||||
@@ -256,8 +263,9 @@ export class DocumentGenerationService {
|
||||
`${template.name || 'document'}-${generation.id}.docx`,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
);
|
||||
fileKeys.docx = key;
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, (await fs.stat(filledPath)).size, request);
|
||||
const docxSize = (await fs.stat(filledPath)).size;
|
||||
fileKeys.docx = { key, size: docxSize };
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.docx`, docxSize, request);
|
||||
}
|
||||
|
||||
if (request.outputFormat === 'pdf' || request.outputFormat === 'both') {
|
||||
@@ -270,27 +278,28 @@ export class DocumentGenerationService {
|
||||
`${template.name || 'document'}-${generation.id}.pdf`,
|
||||
'application/pdf',
|
||||
);
|
||||
fileKeys.pdf = key;
|
||||
fileKeys.pdf = { key, size: pdfBuffer.length };
|
||||
await this.trackFile(key, `${template.name || 'document'}-${generation.id}.pdf`, pdfBuffer.length, request);
|
||||
}
|
||||
|
||||
await documentStorage.updateDocumentGeneration(generation.id, request.organizationId, {
|
||||
status: 'completed',
|
||||
pdfFileKey: fileKeys.pdf || null,
|
||||
docxFileKey: fileKeys.docx || null,
|
||||
pdfFileKey: fileKeys.pdf?.key || null,
|
||||
docxFileKey: fileKeys.docx?.key || null,
|
||||
substitutedData: resolved,
|
||||
unresolvedVariables: unresolved.length > 0 ? unresolved : null,
|
||||
generationDurationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
await this.attachToTaskChat(request, template.name || 'document', fileKeys);
|
||||
await this.saveToTargetField(template, request, fileKeys);
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
return {
|
||||
generationId: generation.id,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx}` : undefined,
|
||||
pdfUrl: fileKeys.pdf ? `/api/files/${fileKeys.pdf.key}` : undefined,
|
||||
docxUrl: fileKeys.docx ? `/api/files/${fileKeys.docx.key}` : undefined,
|
||||
unresolvedVariables: unresolved,
|
||||
status: 'completed',
|
||||
};
|
||||
@@ -323,7 +332,7 @@ export class DocumentGenerationService {
|
||||
private async attachToTaskChat(
|
||||
request: GenerateRequest,
|
||||
templateName: string,
|
||||
fileKeys: { pdf?: string; docx?: string },
|
||||
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
|
||||
): Promise<void> {
|
||||
try {
|
||||
const task = await storage.getTask(request.taskId, request.organizationId);
|
||||
@@ -332,18 +341,18 @@ export class DocumentGenerationService {
|
||||
const attachments: any[] = [];
|
||||
if (fileKeys.pdf) {
|
||||
attachments.push({
|
||||
url: `/api/files/${fileKeys.pdf}`,
|
||||
url: `/api/files/${fileKeys.pdf.key}`,
|
||||
name: `${templateName}.pdf`,
|
||||
mimeType: 'application/pdf',
|
||||
size: 0,
|
||||
size: fileKeys.pdf.size,
|
||||
});
|
||||
}
|
||||
if (fileKeys.docx) {
|
||||
attachments.push({
|
||||
url: `/api/files/${fileKeys.docx}`,
|
||||
url: `/api/files/${fileKeys.docx.key}`,
|
||||
name: `${templateName}.docx`,
|
||||
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
size: 0,
|
||||
size: fileKeys.docx.size,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -362,6 +371,117 @@ export class DocumentGenerationService {
|
||||
}
|
||||
}
|
||||
|
||||
private async saveToTargetField(
|
||||
template: any,
|
||||
request: GenerateRequest,
|
||||
fileKeys: { pdf?: FileKeyInfo; docx?: FileKeyInfo },
|
||||
): Promise<void> {
|
||||
const targetFormId = template.targetFormId as number | undefined;
|
||||
const targetFieldCode = template.targetFieldCode as string | undefined;
|
||||
const targetTaskSource = (template.targetTaskSource as string | undefined) || 'current';
|
||||
const targetSourceFieldCode = template.targetSourceFieldCode as string | undefined;
|
||||
|
||||
if (!targetFormId || !targetFieldCode) return;
|
||||
|
||||
try {
|
||||
const currentTask = await storage.getTask(request.taskId, request.organizationId);
|
||||
if (!currentTask) return;
|
||||
|
||||
let targetTaskId: number | null = null;
|
||||
|
||||
if (targetTaskSource === 'current') {
|
||||
targetTaskId = request.taskId;
|
||||
} else if (targetTaskSource === 'related_task_field') {
|
||||
if (!targetSourceFieldCode) return;
|
||||
const sourceFieldValue = await db
|
||||
.select({ value: taskFieldValues.value })
|
||||
.from(taskFieldValues)
|
||||
.innerJoin(formFields, eq(formFields.id, taskFieldValues.fieldId))
|
||||
.where(and(
|
||||
eq(taskFieldValues.taskId, request.taskId),
|
||||
eq(formFields.code, targetSourceFieldCode),
|
||||
))
|
||||
.limit(1);
|
||||
const rawValue = sourceFieldValue[0]?.value;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === '') return;
|
||||
if (typeof rawValue === 'number') {
|
||||
targetTaskId = rawValue;
|
||||
} else if (typeof rawValue === 'string') {
|
||||
const parsed = Number(rawValue);
|
||||
targetTaskId = Number.isFinite(parsed) ? parsed : null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetTaskId) return;
|
||||
|
||||
const targetTask = await storage.getTask(targetTaskId, request.organizationId);
|
||||
if (!targetTask) return;
|
||||
|
||||
const [targetField] = await db
|
||||
.select()
|
||||
.from(formFields)
|
||||
.where(and(
|
||||
eq(formFields.formId, targetFormId),
|
||||
eq(formFields.code, targetFieldCode),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (!targetField || targetField.type !== 'file') {
|
||||
console.warn(`[Documents] Target field ${targetFieldCode} is not a file field or not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingValues = await storage.getTaskFieldValues(targetTaskId, request.organizationId);
|
||||
const existingValue = existingValues.find((v) => v.fieldId === targetField.id);
|
||||
const existingFiles: Array<{ url: string; name: string; size: number }> = Array.isArray(existingValue?.value)
|
||||
? (existingValue.value as any[])
|
||||
: [];
|
||||
|
||||
const newFiles: Array<{ url: string; name: string; size: number }> = [];
|
||||
if (fileKeys.pdf) {
|
||||
newFiles.push({
|
||||
url: `/api/files/${fileKeys.pdf.key}`,
|
||||
name: `${template.name || 'document'}.pdf`,
|
||||
size: fileKeys.pdf.size,
|
||||
});
|
||||
}
|
||||
if (fileKeys.docx) {
|
||||
newFiles.push({
|
||||
url: `/api/files/${fileKeys.docx.key}`,
|
||||
name: `${template.name || 'document'}.docx`,
|
||||
size: fileKeys.docx.size,
|
||||
});
|
||||
}
|
||||
|
||||
const mergedFiles = [...existingFiles, ...newFiles];
|
||||
|
||||
if (existingValue) {
|
||||
await storage.updateTaskFieldValue(targetTaskId, targetField.id, request.organizationId, {
|
||||
value: mergedFiles,
|
||||
});
|
||||
} else {
|
||||
await storage.createTaskFieldValue({
|
||||
taskId: targetTaskId,
|
||||
fieldId: targetField.id,
|
||||
formId: targetFormId,
|
||||
value: mergedFiles,
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляем fileUploads: привязываем сгенерированные файлы к целевой задаче/полю
|
||||
for (const file of newFiles) {
|
||||
const key = file.url.replace('/api/files/', '');
|
||||
await db.update(fileUploads)
|
||||
.set({ taskId: targetTaskId, fieldId: targetField.id })
|
||||
.where(eq(fileUploads.fileKey, key));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Documents] Failed to save generated document to target field:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async trackFile(
|
||||
key: string,
|
||||
originalName: string,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PackageService } from "./package.service";
|
||||
import { SignatureService } from "./signature.service";
|
||||
import { DocxImporterService } from "./docx-importer.service";
|
||||
import * as storage from "../storage/documents.storage";
|
||||
import { storage as appStorage } from "../storage/index";
|
||||
|
||||
export async function registerDocumentRoutes(app: Express) {
|
||||
const templateService = new DocumentTemplateService();
|
||||
@@ -49,6 +50,79 @@ export async function registerDocumentRoutes(app: Express) {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Template Folders ───────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/documents/folders", authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
const folders = await templateService.listFolders(req.organizationId!);
|
||||
res.json({ success: true, folders });
|
||||
});
|
||||
|
||||
app.post("/api/documents/folders", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
const folder = await templateService.createFolder({
|
||||
...req.body,
|
||||
organizationId: req.organizationId!,
|
||||
});
|
||||
res.status(201).json({ success: true, folder });
|
||||
});
|
||||
|
||||
app.put("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
const folder = await templateService.updateFolder(
|
||||
Number(req.params.id),
|
||||
req.organizationId!,
|
||||
req.body,
|
||||
);
|
||||
if (!folder) return res.status(404).json({ error: "Папка не найдена" });
|
||||
res.json({ success: true, folder });
|
||||
});
|
||||
|
||||
app.delete("/api/documents/folders/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
await templateService.deleteFolder(Number(req.params.id), req.organizationId!);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Ошибка удаления папки";
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/documents/folders/reorder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
await templateService.reorderFolders(req.organizationId!, req.body.updates || []);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Ошибка изменения порядка папок";
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/documents/folders/:id/mappings", authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
const mappings = await templateService.listFolderMappings(Number(req.params.id), req.organizationId!);
|
||||
res.json({ success: true, mappings });
|
||||
});
|
||||
|
||||
app.post("/api/documents/folders/:id/mappings", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
const mapping = await templateService.createFolderMapping({
|
||||
...req.body,
|
||||
folderId: Number(req.params.id),
|
||||
});
|
||||
res.status(201).json({ success: true, mapping });
|
||||
});
|
||||
|
||||
app.put("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
const mapping = await templateService.updateFolderMapping(
|
||||
Number(req.params.mappingId),
|
||||
Number(req.params.id),
|
||||
req.body,
|
||||
);
|
||||
if (!mapping) return res.status(404).json({ error: "Маппинг не найден" });
|
||||
res.json({ success: true, mapping });
|
||||
});
|
||||
|
||||
app.delete("/api/documents/folders/:id/mappings/:mappingId", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
await templateService.deleteFolderMapping(Number(req.params.mappingId), Number(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Templates ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/documents/templates", authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
@@ -56,6 +130,7 @@ export async function registerDocumentRoutes(app: Express) {
|
||||
organizationId: req.organizationId!,
|
||||
status: req.query.status as string | undefined,
|
||||
categoryId: req.query.categoryId ? Number(req.query.categoryId) : undefined,
|
||||
folderId: req.query.folderId ? Number(req.query.folderId) : undefined,
|
||||
formId: req.query.formId ? Number(req.query.formId) : undefined,
|
||||
});
|
||||
res.json({ success: true, templates });
|
||||
@@ -132,6 +207,16 @@ export async function registerDocumentRoutes(app: Express) {
|
||||
res.json({ success: true, template });
|
||||
});
|
||||
|
||||
app.patch("/api/documents/templates/:id/folder", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
const template = await templateService.moveTemplateToFolder(
|
||||
Number(req.params.id),
|
||||
req.organizationId!,
|
||||
req.body.folderId === null ? null : Number(req.body.folderId),
|
||||
);
|
||||
if (!template) return res.status(404).json({ error: "Шаблон не найден" });
|
||||
res.json({ success: true, template });
|
||||
});
|
||||
|
||||
app.delete("/api/documents/templates/:id", authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
await templateService.delete(Number(req.params.id), req.organizationId!);
|
||||
@@ -236,6 +321,40 @@ export async function registerDocumentRoutes(app: Express) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/documents/generate-link", authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const templateId = Number(req.query.templateId);
|
||||
const taskId = Number(req.query.taskId);
|
||||
const format = (req.query.format as string) || 'both';
|
||||
const returnTo = (req.query.returnTo as string) || '';
|
||||
|
||||
if (!templateId || !taskId) {
|
||||
return res.status(400).json({ error: "templateId и taskId обязательны" });
|
||||
}
|
||||
if (!['pdf', 'docx', 'both'].includes(format)) {
|
||||
return res.status(400).json({ error: "Недопустимый формат" });
|
||||
}
|
||||
|
||||
await generationService.generate({
|
||||
templateId,
|
||||
taskId,
|
||||
organizationId: req.organizationId!,
|
||||
userId: req.user!.id,
|
||||
outputFormat: format as any,
|
||||
});
|
||||
|
||||
let redirectUrl = returnTo;
|
||||
if (!redirectUrl) {
|
||||
const task = await appStorage.getTask(taskId, req.organizationId!);
|
||||
redirectUrl = task ? `/forms/${task.formId}/tasks/${taskId}` : '/';
|
||||
}
|
||||
res.redirect(redirectUrl);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Ошибка генерации документа";
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/documents/generations/preview", authenticateToken, async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const preview = await generationService.preview(
|
||||
|
||||
@@ -8,13 +8,36 @@ import {
|
||||
type InsertDocumentTemplateCategory,
|
||||
type TemplateVariable,
|
||||
type InsertTemplateVariable,
|
||||
type DocumentFolderMapping,
|
||||
type DocumentTemplateFolder,
|
||||
type InsertDocumentTemplateFolder,
|
||||
type InsertDocumentFolderMapping,
|
||||
} from "@shared/schema";
|
||||
import * as storage from "../storage/documents.storage";
|
||||
|
||||
const VARIABLE_REGEX = /\{\{\s*([^}]+?)\s*\}\}/g;
|
||||
|
||||
const VALID_SOURCES = new Set([
|
||||
'static',
|
||||
'task_field',
|
||||
'task_system',
|
||||
'task_table_tab',
|
||||
'related_task_field',
|
||||
'asset',
|
||||
'assignee',
|
||||
'creator',
|
||||
'organization',
|
||||
'current_user',
|
||||
'current_date',
|
||||
]);
|
||||
|
||||
export interface EnrichedVariable extends TemplateVariable {
|
||||
isFolderMapping?: boolean;
|
||||
folderMappingId?: number;
|
||||
}
|
||||
|
||||
export interface TemplateWithVariables extends DocumentTemplate {
|
||||
variables: TemplateVariable[];
|
||||
variables: EnrichedVariable[];
|
||||
category?: DocumentTemplateCategory | null;
|
||||
}
|
||||
|
||||
@@ -45,12 +68,112 @@ export class DocumentTemplateService {
|
||||
return storage.deleteDocumentTemplateCategory(id, organizationId);
|
||||
}
|
||||
|
||||
// ── Folders ────────────────────────────────────────────────────────────────
|
||||
|
||||
async listFolders(organizationId: number): Promise<DocumentTemplateFolder[]> {
|
||||
return storage.listDocumentTemplateFolders(organizationId);
|
||||
}
|
||||
|
||||
async getFolder(id: number, organizationId: number): Promise<DocumentTemplateFolder | undefined> {
|
||||
return storage.getDocumentTemplateFolder(id, organizationId);
|
||||
}
|
||||
|
||||
async createFolder(data: InsertDocumentTemplateFolder): Promise<DocumentTemplateFolder> {
|
||||
return storage.createDocumentTemplateFolder(data);
|
||||
}
|
||||
|
||||
async updateFolder(
|
||||
id: number,
|
||||
organizationId: number,
|
||||
updates: Partial<DocumentTemplateFolder>,
|
||||
): Promise<DocumentTemplateFolder | undefined> {
|
||||
return storage.updateDocumentTemplateFolder(id, organizationId, updates);
|
||||
}
|
||||
|
||||
async deleteFolder(id: number, organizationId: number): Promise<void> {
|
||||
const folder = await this.getFolder(id, organizationId);
|
||||
if (!folder) throw new Error('Папка не найдена');
|
||||
|
||||
// Переносим дочерние папки на уровень удаляемой (parentId = удаляемой.parentId)
|
||||
const allFolders = await this.listFolders(organizationId);
|
||||
const children = allFolders.filter((f) => f.parentId === id);
|
||||
for (const child of children) {
|
||||
await storage.updateDocumentTemplateFolder(child.id, organizationId, { parentId: folder.parentId });
|
||||
}
|
||||
|
||||
// Удаляем маппинги папки
|
||||
await storage.deleteDocumentFolderMappingsByFolder(id);
|
||||
|
||||
// Удаляем саму папку
|
||||
await storage.deleteDocumentTemplateFolder(id, organizationId);
|
||||
}
|
||||
|
||||
async reorderFolders(
|
||||
organizationId: number,
|
||||
updates: Array<{ id: number; parentId: number | null; position: number }>,
|
||||
): Promise<void> {
|
||||
for (const update of updates) {
|
||||
const folder = await this.getFolder(update.id, organizationId);
|
||||
if (!folder) continue;
|
||||
await storage.updateDocumentTemplateFolder(update.id, organizationId, {
|
||||
parentId: update.parentId,
|
||||
position: update.position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async moveTemplateToFolder(
|
||||
templateId: number,
|
||||
organizationId: number,
|
||||
folderId: number | null,
|
||||
): Promise<DocumentTemplate | undefined> {
|
||||
const template = await storage.getDocumentTemplate(templateId, organizationId);
|
||||
if (!template) return undefined;
|
||||
if (folderId !== null) {
|
||||
const folder = await this.getFolder(folderId, organizationId);
|
||||
if (!folder) throw new Error('Папка не найдена');
|
||||
}
|
||||
return storage.updateDocumentTemplate(templateId, organizationId, { folderId });
|
||||
}
|
||||
|
||||
// ── Folder Mappings ────────────────────────────────────────────────────────
|
||||
|
||||
async listFolderMappings(folderId: number, organizationId: number): Promise<DocumentFolderMapping[]> {
|
||||
const folder = await this.getFolder(folderId, organizationId);
|
||||
if (!folder) throw new Error('Папка не найдена');
|
||||
return storage.listDocumentFolderMappings(folderId);
|
||||
}
|
||||
|
||||
async createFolderMapping(data: InsertDocumentFolderMapping): Promise<DocumentFolderMapping> {
|
||||
this.validateVariable(data, 0);
|
||||
return storage.createDocumentFolderMapping(data);
|
||||
}
|
||||
|
||||
async updateFolderMapping(
|
||||
id: number,
|
||||
folderId: number,
|
||||
updates: Partial<InsertDocumentFolderMapping>,
|
||||
): Promise<DocumentFolderMapping | undefined> {
|
||||
if (updates.source !== undefined || updates.mapping !== undefined) {
|
||||
this.validateVariable(
|
||||
{ source: updates.source || 'static', mapping: updates.mapping || {} },
|
||||
0,
|
||||
);
|
||||
}
|
||||
return storage.updateDocumentFolderMapping(id, folderId, updates);
|
||||
}
|
||||
|
||||
async deleteFolderMapping(id: number, folderId: number): Promise<void> {
|
||||
return storage.deleteDocumentFolderMapping(id, folderId);
|
||||
}
|
||||
|
||||
// ── Templates ──────────────────────────────────────────────────────────────
|
||||
|
||||
async list(filters: {
|
||||
organizationId: number;
|
||||
status?: string;
|
||||
categoryId?: number;
|
||||
folderId?: number;
|
||||
formId?: number;
|
||||
}): Promise<DocumentTemplate[]> {
|
||||
return storage.listDocumentTemplates(filters);
|
||||
@@ -59,26 +182,72 @@ export class DocumentTemplateService {
|
||||
async getById(id: number, organizationId: number): Promise<TemplateWithVariables | undefined> {
|
||||
const template = await storage.getDocumentTemplate(id, organizationId);
|
||||
if (!template) return undefined;
|
||||
const variables = await storage.listTemplateVariables(id);
|
||||
const templateVariables = await storage.listTemplateVariables(id);
|
||||
const category = template.categoryId ? await this.getCategory(template.categoryId, organizationId) : null;
|
||||
const variables = await this.mergeFolderVariables(template, templateVariables);
|
||||
return { ...template, variables, category: category || undefined };
|
||||
}
|
||||
|
||||
private async mergeFolderVariables(
|
||||
template: DocumentTemplate,
|
||||
templateVariables: TemplateVariable[],
|
||||
): Promise<EnrichedVariable[]> {
|
||||
if (!template.folderId) {
|
||||
return templateVariables.map((v) => ({ ...v, isFolderMapping: false }));
|
||||
}
|
||||
|
||||
const folderMappings = await storage.listDocumentFolderMappings(template.folderId);
|
||||
const merged = new Map<string, EnrichedVariable>();
|
||||
|
||||
for (const m of folderMappings) {
|
||||
merged.set(m.code, this.mappingToVariable(m, template.id));
|
||||
}
|
||||
|
||||
// Переменные шаблона переопределяют маппинги папки
|
||||
for (const v of templateVariables) {
|
||||
merged.set(v.code, { ...v, isFolderMapping: false });
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).sort((a, b) => {
|
||||
const orderDiff = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
private mappingToVariable(mapping: DocumentFolderMapping, templateId: number): EnrichedVariable {
|
||||
return {
|
||||
id: -mapping.id,
|
||||
templateId,
|
||||
code: mapping.code,
|
||||
label: mapping.label,
|
||||
description: mapping.description,
|
||||
source: mapping.source,
|
||||
mapping: mapping.mapping,
|
||||
defaultValue: mapping.defaultValue,
|
||||
sortOrder: mapping.sortOrder ?? 0,
|
||||
createdAt: mapping.createdAt,
|
||||
isFolderMapping: true,
|
||||
folderMappingId: mapping.id,
|
||||
};
|
||||
}
|
||||
|
||||
async getByIdRaw(id: number, organizationId: number): Promise<DocumentTemplate | undefined> {
|
||||
return storage.getDocumentTemplate(id, organizationId);
|
||||
}
|
||||
|
||||
async create(data: InsertDocumentTemplate & { variables?: InsertTemplateVariable[] }): Promise<TemplateWithVariables> {
|
||||
const template = await storage.createDocumentTemplate(data);
|
||||
const variables: TemplateVariable[] = [];
|
||||
|
||||
if (data.variables && data.variables.length > 0) {
|
||||
for (const v of data.variables) {
|
||||
variables.push(await storage.createTemplateVariable({ ...v, templateId: template.id }));
|
||||
for (let i = 0; i < data.variables.length; i++) {
|
||||
const v = data.variables[i];
|
||||
this.validateVariable(v, i);
|
||||
await storage.createTemplateVariable({ ...v, templateId: template.id });
|
||||
}
|
||||
}
|
||||
|
||||
return { ...template, variables };
|
||||
return this.getById(template.id, template.organizationId) as Promise<TemplateWithVariables>;
|
||||
}
|
||||
|
||||
async update(
|
||||
@@ -99,6 +268,8 @@ export class DocumentTemplateService {
|
||||
|
||||
for (let i = 0; i < updates.variables.length; i++) {
|
||||
const v = updates.variables[i];
|
||||
if (v.id && v.id < 0) continue; // маппинги папки редактируются отдельно
|
||||
this.validateVariable(v, i);
|
||||
const variableData: InsertTemplateVariable = {
|
||||
templateId: id,
|
||||
code: v.code || `var_${i}`,
|
||||
@@ -120,6 +291,32 @@ export class DocumentTemplateService {
|
||||
return this.getById(id, organizationId);
|
||||
}
|
||||
|
||||
private validateVariable(v: Partial<TemplateVariable>, index: number): void {
|
||||
const source = v.source || 'static';
|
||||
if (!VALID_SOURCES.has(source)) {
|
||||
throw new Error(`Некорректный источник переменной #${index + 1}: ${source}`);
|
||||
}
|
||||
|
||||
const mapping = (v.mapping || {}) as Record<string, any>;
|
||||
|
||||
if (source === 'related_task_field') {
|
||||
if (!mapping.relationType) {
|
||||
throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать тип связи`);
|
||||
}
|
||||
if (!mapping.targetFieldCode) {
|
||||
throw new Error(`Для переменной #${index + 1} с источником «Связанная задача» необходимо указать целевое поле`);
|
||||
}
|
||||
}
|
||||
|
||||
if (source === 'task_field' && !mapping.fieldCode) {
|
||||
throw new Error(`Для переменной #${index + 1} с источником «Поле задачи» необходимо указать код поля`);
|
||||
}
|
||||
|
||||
if (source === 'task_table_tab' && !mapping.tabId) {
|
||||
throw new Error(`Для переменной #${index + 1} с источником «Таблица задачи» необходимо выбрать вкладку`);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: number, organizationId: number): Promise<void> {
|
||||
await storage.deleteTemplateVariablesByTemplate(id);
|
||||
await storage.deleteDocumentTemplate(id, organizationId);
|
||||
@@ -140,7 +337,8 @@ export class DocumentTemplateService {
|
||||
|
||||
const variables: TemplateVariable[] = [];
|
||||
for (const v of source.variables) {
|
||||
const { id: _vid, templateId, createdAt: _vCreatedAt, ...vRest } = v;
|
||||
if (v.isFolderMapping) continue;
|
||||
const { id: _vid, templateId, createdAt: _vCreatedAt, isFolderMapping, folderMappingId, ...vRest } = v;
|
||||
variables.push(
|
||||
await storage.createTemplateVariable({
|
||||
...vRest,
|
||||
|
||||
@@ -133,20 +133,40 @@ app.get('/api/files/:key/presigned', authenticateToken, async (req: Authenticate
|
||||
});
|
||||
|
||||
// Helper to set Content-Type and Content-Disposition headers based on file extension.
|
||||
function setFileResponseHeaders(res: Response, fileKey: string, overrideContentType?: string, overrideContentDisposition?: string) {
|
||||
function setFileResponseHeaders(
|
||||
res: Response,
|
||||
fileKey: string,
|
||||
overrideContentType?: string,
|
||||
overrideContentDisposition?: string,
|
||||
originalName?: string | null,
|
||||
) {
|
||||
const ext = getFileExt(fileKey);
|
||||
const contentType = overrideContentType || EXT_TO_MIME[ext]?.[0] || 'application/octet-stream';
|
||||
const isPdf = ext === 'pdf';
|
||||
const utf8Name = originalName ? `filename*=UTF-8''${encodeURIComponent(originalName)}` : '';
|
||||
// PDFs are always shown inline (browser iframe preview).
|
||||
// Other files use S3-provided disposition if available, defaulting to attachment.
|
||||
// Prefer file_uploads.originalName (UTF-8) over raw fileKey to avoid encoding issues.
|
||||
const contentDisposition = isPdf
|
||||
? `inline; filename="${fileKey}"`
|
||||
: (overrideContentDisposition || `attachment; filename="${fileKey}"`);
|
||||
? (utf8Name ? `inline; ${utf8Name}` : `inline; filename="${fileKey}"`)
|
||||
: (overrideContentDisposition || (utf8Name ? `attachment; ${utf8Name}` : `attachment; filename="${fileKey}"`));
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
async function getFileOriginalName(fileKey: string): Promise<string | undefined> {
|
||||
try {
|
||||
const rows = await db.select({ originalName: fileUploads.originalName })
|
||||
.from(fileUploads)
|
||||
.where(eq(fileUploads.fileKey, fileKey))
|
||||
.limit(1);
|
||||
return rows[0]?.originalName || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (isS3Enabled) {
|
||||
// S3/MinIO mode: files served via /api/files/:key.
|
||||
app.use('/api/files/:key', tryPresignedOrAuth(), async (req: AuthenticatedRequest, res: Response) => {
|
||||
@@ -160,7 +180,8 @@ if (isS3Enabled) {
|
||||
|
||||
const obj = await streamFromS3(key);
|
||||
if (!obj) return res.status(404).end();
|
||||
setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined);
|
||||
const originalName = await getFileOriginalName(key);
|
||||
setFileResponseHeaders(res, key, obj.contentType, obj.contentDisposition || undefined, originalName);
|
||||
if (obj.contentLength) res.setHeader('Content-Length', obj.contentLength);
|
||||
(obj.body as any).pipe(res);
|
||||
});
|
||||
@@ -181,7 +202,8 @@ if (isS3Enabled) {
|
||||
|
||||
const filePath = path.join(uploadsDir, filename);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).end();
|
||||
setFileResponseHeaders(res, filename);
|
||||
const originalName = await getFileOriginalName(filename);
|
||||
setFileResponseHeaders(res, filename, undefined, undefined, originalName);
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
}
|
||||
@@ -1100,10 +1122,12 @@ async function runStartupDataPatches() {
|
||||
// this serves both the API and the client.
|
||||
// It is the only port that is not firewalled.
|
||||
const port = parseInt(process.env.PORT || '5000', 10);
|
||||
// reusePort is not supported on Windows; skip it for native Windows dev
|
||||
const isWindows = process.platform === 'win32';
|
||||
server.listen({
|
||||
port,
|
||||
host: "0.0.0.0",
|
||||
reusePort: true,
|
||||
...(isWindows ? {} : { reusePort: true }),
|
||||
}, () => {
|
||||
log(`serving on port ${port}`);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken, type AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware';
|
||||
import { tenantIsolation } from '../middleware/tenant.middleware';
|
||||
import { storage } from '../storage';
|
||||
import { pool } from '../db';
|
||||
import { executeTaskTransition } from '../services/task-transition.service';
|
||||
import { tasksMinimalCache } from '../utils/cache';
|
||||
import type { ReminderRecipient } from '@shared/schema';
|
||||
|
||||
const taskBulkRouter = Router();
|
||||
@@ -250,4 +251,50 @@ taskBulkRouter.post('/api/tasks/bulk/transition',
|
||||
}
|
||||
);
|
||||
|
||||
// Delete multiple tasks (admin only)
|
||||
// DELETE /api/tasks/bulk
|
||||
// Body: { taskIds: number[] }
|
||||
taskBulkRouter.delete('/api/tasks/bulk',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
requirePermission('tasks.edit_all'),
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const taskIds = validateTaskIds(req.body);
|
||||
if (!taskIds) {
|
||||
return res.status(400).json({ success: false, error: 'Укажите от 1 до 100 ID задач' });
|
||||
}
|
||||
|
||||
const organizationId = req.user!.organizationId;
|
||||
let deleted = 0;
|
||||
const errors: Array<{ taskId: number; error: string }> = [];
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const existingTask = await storage.getTask(taskId, organizationId);
|
||||
if (!existingTask) {
|
||||
errors.push({ taskId, error: 'Задача не найдена' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await storage.deleteTask(taskId, organizationId);
|
||||
deleted++;
|
||||
} catch (err: any) {
|
||||
console.error(`[bulk/delete] Failed to delete task ${taskId}:`, err);
|
||||
errors.push({ taskId, error: err?.message || 'Ошибка удаления' });
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted > 0) {
|
||||
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
|
||||
}
|
||||
|
||||
res.json({ success: true, deleted, total: taskIds.length, errors: errors.length > 0 ? errors : undefined });
|
||||
} catch (err) {
|
||||
console.error('[bulk/delete]', err);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при массовом удалении задач' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default taskBulkRouter;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import { insertTaskSchema } from "@shared/schema";
|
||||
import { insertTaskSchema, idempotencyKeys, taskFieldValues, taskAuditLog } from "@shared/schema";
|
||||
import { notificationService } from "../services/notification.service";
|
||||
import { buildSystemFieldValues, buildTableRowMap, commitPendingUploads, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
|
||||
import { tasksMinimalCache } from "../utils/cache";
|
||||
@@ -15,6 +16,8 @@ import { validateRequiredFields } from "../utils/validate-required-fields";
|
||||
import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service";
|
||||
import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes";
|
||||
import { normalizeFieldValueForStorage } from "../utils/normalize-field-value";
|
||||
import { db, withTenant } from "../db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
|
||||
function normalizeCustomFields(
|
||||
customFields: Record<string, unknown> | undefined,
|
||||
@@ -706,7 +709,7 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
return res.status(404).json({ success: false, error: 'Форма не найдена' });
|
||||
}
|
||||
|
||||
const { tasks: taskList } = req.body;
|
||||
const { tasks: taskList, idempotencyKey } = req.body as { tasks?: unknown[]; idempotencyKey?: string };
|
||||
if (!Array.isArray(taskList) || taskList.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'Необходимо передать массив задач' });
|
||||
}
|
||||
@@ -735,12 +738,18 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
// Pre-build table row map once (used by title template and auto-transitions)
|
||||
const tableRowMap = await buildTableRowMap(formFields, req.organizationId!);
|
||||
|
||||
const createdIds: number[] = [];
|
||||
interface PreparedTask {
|
||||
taskData: any;
|
||||
normalizedCustomFields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Phase 1: validate and run pre-create automations for ALL tasks before any DB write ──
|
||||
const preparedTasks: PreparedTask[] = [];
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const taskBody = taskList[i];
|
||||
const taskBody = taskList[i] as any;
|
||||
const { customFields, dueDate, ...baseTaskData } = taskBody;
|
||||
|
||||
let parsedDueDate = null;
|
||||
let parsedDueDate: Date | null = null;
|
||||
if (dueDate && dueDate !== '') {
|
||||
if (dueDate instanceof Date) {
|
||||
parsedDueDate = dueDate;
|
||||
@@ -758,6 +767,49 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCustomFields = normalizeCustomFields(customFields, formFields);
|
||||
|
||||
const validationErrors = await validateRequiredFields({
|
||||
task: { currentStatusId: initialStatus.id, formId },
|
||||
formFields,
|
||||
customFields: normalizedCustomFields,
|
||||
organizationId: req.organizationId!,
|
||||
storage,
|
||||
isCreation: true,
|
||||
statuses: formStatusesList,
|
||||
users,
|
||||
roles,
|
||||
});
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Ошибка в элементе ${i + 1}: ${validationErrors.join('; ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', {
|
||||
formId,
|
||||
task: {
|
||||
formId,
|
||||
title: baseTaskData.title,
|
||||
currentStatusId: initialStatus.id,
|
||||
dueDate: parsedDueDate,
|
||||
customFields: normalizedCustomFields,
|
||||
},
|
||||
});
|
||||
for (const run of preCreateResults) {
|
||||
const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined;
|
||||
if (result && result.allow === false) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Элемент ${i + 1}: ${result.error || 'Создание задачи запрещено автоматизацией'}`,
|
||||
automationId: run.automationId,
|
||||
activeRentalTaskId: result.activeRentalTaskId,
|
||||
assignedToName: result.assignedToName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
...baseTaskData,
|
||||
formId,
|
||||
@@ -767,168 +819,221 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
currentStatusId: initialStatus.id,
|
||||
};
|
||||
|
||||
// Validate conditional required fields for each batch item
|
||||
const batchValidationErrors = await validateRequiredFields({
|
||||
task: { currentStatusId: initialStatus.id, formId },
|
||||
formFields,
|
||||
customFields,
|
||||
organizationId: req.organizationId!,
|
||||
storage,
|
||||
isCreation: true,
|
||||
statuses: formStatusesList,
|
||||
users,
|
||||
roles,
|
||||
});
|
||||
if (batchValidationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Ошибка в элементе ${i + 1}: ${batchValidationErrors.join('; ')}`,
|
||||
});
|
||||
}
|
||||
preparedTasks.push({ taskData, normalizedCustomFields });
|
||||
}
|
||||
|
||||
let task = await storage.createTask(taskData);
|
||||
const payloadHash = crypto.createHash('sha256').update(JSON.stringify(taskList)).digest('hex');
|
||||
const endpointPath = `/api/forms/${formId}/tasks/batch`;
|
||||
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
|
||||
// Sync the primary assignee into task_assignees so TaskDetail can display it
|
||||
if (task.assignedTo) {
|
||||
await storage.addTaskAssignee(task.id, task.assignedTo, req.organizationId!).catch(() => {});
|
||||
}
|
||||
|
||||
if (customFields && typeof customFields === 'object') {
|
||||
const normalizedCustomFields = normalizeCustomFields(customFields, formFields);
|
||||
for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) {
|
||||
const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/);
|
||||
if (fieldIdMatch && value !== undefined && value !== '') {
|
||||
const fieldId = parseInt(fieldIdMatch[1]);
|
||||
const field = fieldMap.get(fieldId);
|
||||
if (!field) continue;
|
||||
|
||||
await storage.createTaskFieldValue({
|
||||
taskId: task.id,
|
||||
fieldId,
|
||||
formId,
|
||||
value: normalizeFieldValueForStorage(value, field.type)
|
||||
});
|
||||
// ── Phase 2: atomic creation inside one tenant transaction ──
|
||||
const txResult = await withTenant(req.organizationId!, async () => {
|
||||
// Idempotency check (inside transaction so it shares RLS context)
|
||||
if (idempotencyKey && idempotencyKey.length <= 255) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(idempotencyKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(idempotencyKeys.organizationId, req.organizationId!),
|
||||
eq(idempotencyKeys.key, idempotencyKey),
|
||||
sql`${idempotencyKeys.expiresAt} > NOW()`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
const stored = existing[0];
|
||||
if (stored.payloadHash !== payloadHash) {
|
||||
throw new Error('Idempotency key used with different payload');
|
||||
}
|
||||
const resp = stored.responseJson as { created?: number; taskIds?: number[] };
|
||||
return { cached: true as const, created: resp.created ?? 0, taskIds: resp.taskIds ?? [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (existingForm.titleTemplate) {
|
||||
const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!);
|
||||
const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value]));
|
||||
const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap });
|
||||
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!);
|
||||
const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => {
|
||||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||||
const field = fieldCodeMap.get(code);
|
||||
if (!field) return '';
|
||||
const val = valueByFieldId.get(field.id);
|
||||
if (val === null || val === undefined) return '';
|
||||
if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) {
|
||||
return field.autoProlongationLabel || 'Автопролонгация';
|
||||
}
|
||||
return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options);
|
||||
});
|
||||
task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
|
||||
}
|
||||
const createdIds: number[] = [];
|
||||
const fieldValuesToInsert: any[] = [];
|
||||
const auditLogsToInsert: any[] = [];
|
||||
const assigneesToAdd: Array<{ taskId: number; userId: number }> = [];
|
||||
const autoTransitionTasks: Array<{ taskId: number; assignedTo?: number | null }> = [];
|
||||
|
||||
// Auto-assign task based on transition rules from current status
|
||||
if (!task.assignedTo && task.currentStatusId) {
|
||||
const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId);
|
||||
let targetUserId: number | null = null;
|
||||
for (let i = 0; i < preparedTasks.length; i++) {
|
||||
const { taskData, normalizedCustomFields } = preparedTasks[i];
|
||||
|
||||
for (const transition of outgoingTransitions) {
|
||||
if (transition.assigneeUserId) {
|
||||
targetUserId = transition.assigneeUserId;
|
||||
break;
|
||||
let task = await storage.createTask(taskData);
|
||||
|
||||
if (task.assignedTo) {
|
||||
assigneesToAdd.push({ taskId: task.id, userId: task.assignedTo });
|
||||
}
|
||||
|
||||
// Collect field values for batch insert
|
||||
if (normalizedCustomFields && typeof normalizedCustomFields === 'object') {
|
||||
for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) {
|
||||
const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/);
|
||||
if (fieldIdMatch && value !== undefined && value !== '') {
|
||||
const fieldId = parseInt(fieldIdMatch[1]);
|
||||
const field = fieldMap.get(fieldId);
|
||||
if (!field) continue;
|
||||
fieldValuesToInsert.push({
|
||||
taskId: task.id,
|
||||
fieldId,
|
||||
formId,
|
||||
value: normalizeFieldValueForStorage(value, field.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transition.assigneeConditions) {
|
||||
const conditions = transition.assigneeConditions as Record<string, unknown>;
|
||||
if (conditions.assignees && Array.isArray(conditions.assignees)) {
|
||||
const firstUser = conditions.assignees.find((a: any) => a.type === 'user');
|
||||
if (firstUser?.id) {
|
||||
targetUserId = firstUser.id;
|
||||
}
|
||||
|
||||
if (existingForm.titleTemplate) {
|
||||
const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!);
|
||||
const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value]));
|
||||
const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap });
|
||||
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!);
|
||||
const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => {
|
||||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||||
const field = fieldCodeMap.get(code);
|
||||
if (!field) return '';
|
||||
const val = valueByFieldId.get(field.id);
|
||||
if (val === null || val === undefined) return '';
|
||||
if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) {
|
||||
return field.autoProlongationLabel || 'Автопролонгация';
|
||||
}
|
||||
return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options);
|
||||
});
|
||||
task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
|
||||
}
|
||||
|
||||
// Auto-assign task based on transition rules from current status
|
||||
if (!task.assignedTo && task.currentStatusId) {
|
||||
const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId);
|
||||
let targetUserId: number | null = null;
|
||||
|
||||
for (const transition of outgoingTransitions) {
|
||||
if (transition.assigneeUserId) {
|
||||
targetUserId = transition.assigneeUserId;
|
||||
break;
|
||||
}
|
||||
if (transition.assigneeConditions) {
|
||||
const conditions = transition.assigneeConditions as Record<string, unknown>;
|
||||
if (conditions.assignees && Array.isArray(conditions.assignees)) {
|
||||
const firstUser = conditions.assignees.find((a: any) => a.type === 'user');
|
||||
if (firstUser?.id) {
|
||||
targetUserId = firstUser.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (conditions.fallbackAssigneeUserId) {
|
||||
targetUserId = conditions.fallbackAssigneeUserId as number;
|
||||
break;
|
||||
}
|
||||
if (conditions.assigneeId) {
|
||||
targetUserId = conditions.assigneeId as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (conditions.fallbackAssigneeUserId) {
|
||||
targetUserId = conditions.fallbackAssigneeUserId as number;
|
||||
break;
|
||||
}
|
||||
if (conditions.assigneeId) {
|
||||
targetUserId = conditions.assigneeId as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId });
|
||||
assigneesToAdd.push({ taskId: task.id, userId: targetUserId });
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId });
|
||||
await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {});
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
auditLogsToInsert.push({
|
||||
taskId: task.id,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'task.created',
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
});
|
||||
|
||||
createdIds.push(task.id);
|
||||
autoTransitionTasks.push({ taskId: task.id, assignedTo: task.assignedTo });
|
||||
}
|
||||
|
||||
// Batch insert all custom field values
|
||||
if (fieldValuesToInsert.length > 0) {
|
||||
await db.insert(taskFieldValues).values(fieldValuesToInsert);
|
||||
}
|
||||
|
||||
// Batch insert all audit logs
|
||||
if (auditLogsToInsert.length > 0) {
|
||||
await db.insert(taskAuditLog).values(auditLogsToInsert as any);
|
||||
}
|
||||
|
||||
// Sync assignees
|
||||
for (const { taskId, userId } of assigneesToAdd) {
|
||||
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
|
||||
}
|
||||
|
||||
// Persist idempotency key atomically with the created tasks
|
||||
if (idempotencyKey && idempotencyKey.length <= 255) {
|
||||
await db.insert(idempotencyKeys).values({
|
||||
key: idempotencyKey,
|
||||
organizationId: req.organizationId!,
|
||||
userId: req.user!.id,
|
||||
endpoint: endpointPath,
|
||||
payloadHash,
|
||||
responseJson: { created: createdIds.length, taskIds: createdIds },
|
||||
expiresAt: sql`NOW() + INTERVAL '1 hour'`,
|
||||
});
|
||||
}
|
||||
|
||||
return { cached: false as const, created: createdIds.length, taskIds: createdIds, autoTransitionTasks };
|
||||
});
|
||||
|
||||
// ── Phase 3: post-commit side effects (must happen AFTER commit so other requests see data) ──
|
||||
if (!txResult.cached) {
|
||||
for (const { taskId, assignedTo } of txResult.autoTransitionTasks) {
|
||||
const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, {
|
||||
triggeredBy: req.user?.id ?? null,
|
||||
preloaded: {
|
||||
statuses: formStatusesList,
|
||||
formFields,
|
||||
users,
|
||||
roles,
|
||||
transitions,
|
||||
}
|
||||
});
|
||||
let finalTask = await storage.getTask(taskId, req.organizationId!);
|
||||
if (autoResult.changed && finalTask) {
|
||||
finalTask = await storage.getTask(taskId, req.organizationId!) ?? finalTask;
|
||||
}
|
||||
}
|
||||
if (!finalTask) continue;
|
||||
|
||||
// Audit log
|
||||
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task.id,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'task.created',
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
}).catch(() => {});
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
organizationId: req.organizationId!,
|
||||
data: { taskId, formId, task: finalTask }
|
||||
});
|
||||
|
||||
// Notify assignee
|
||||
if (task.assignedTo && task.assignedTo !== req.user!.id) {
|
||||
notifyTaskAssigned(task, task.assignedTo, req.user!.id, req.organizationId!)
|
||||
.catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
// Evaluate auto-transitions based on entry conditions (with preloaded data)
|
||||
const autoResult = await evaluateAutoTransitions(task.id, req.organizationId!, {
|
||||
triggeredBy: req.user?.id ?? null,
|
||||
preloaded: {
|
||||
statuses: formStatusesList,
|
||||
formFields,
|
||||
users,
|
||||
roles,
|
||||
transitions,
|
||||
if (assignedTo && assignedTo !== req.user!.id) {
|
||||
notifyTaskAssigned(finalTask, assignedTo, req.user!.id, req.organizationId!)
|
||||
.catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err));
|
||||
}
|
||||
});
|
||||
if (autoResult.changed) {
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
}
|
||||
|
||||
// Notify other users in real-time
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
organizationId: req.organizationId!,
|
||||
data: { taskId: task.id, formId: task.formId, task: task }
|
||||
});
|
||||
|
||||
indexTaskAsync(task.id, req.organizationId!).catch(() => {});
|
||||
createdIds.push(task.id);
|
||||
|
||||
// Yield to event loop to prevent blocking other requests
|
||||
if (i < taskList.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
|
||||
|
||||
res.status(201).json({
|
||||
res.status(txResult.cached ? 200 : 201).json({
|
||||
success: true,
|
||||
message: `Создано ${createdIds.length} задач`,
|
||||
created: createdIds.length,
|
||||
taskIds: createdIds
|
||||
message: txResult.cached
|
||||
? `Задачи уже созданы (${txResult.created})`
|
||||
: `Создано ${txResult.created} задач`,
|
||||
created: txResult.created,
|
||||
taskIds: txResult.taskIds,
|
||||
cached: txResult.cached,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Batch create tasks error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при пакетном создании задач' });
|
||||
const message = error instanceof Error ? error.message : 'Ошибка при пакетном создании задач';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -434,7 +434,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
return res.status(400).json({ success: false, error: immutableErrorsSingle.join('\n') });
|
||||
}
|
||||
|
||||
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact']);
|
||||
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'document_template']);
|
||||
if (!INLINE_EDITABLE_FIELD_TYPES.has(field.type)) {
|
||||
return res.status(400).json({ success: false, error: `Поле типа «${field.type}» не поддерживает inline-редактирование` });
|
||||
}
|
||||
@@ -607,7 +607,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
|
||||
}
|
||||
const fieldIds = entries.map(([k]) => parseInt(k, 10));
|
||||
|
||||
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact']);
|
||||
const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact', 'document_template']);
|
||||
const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
|
||||
const fieldMap = new Map(formFields.map(f => [f.id, f]));
|
||||
const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
documentTemplateCategories,
|
||||
documentTemplateFolders,
|
||||
documentFolderMappings,
|
||||
documentTemplates,
|
||||
templateVariables,
|
||||
organizationAssets,
|
||||
@@ -9,6 +11,10 @@ import {
|
||||
documentSignatures,
|
||||
type DocumentTemplateCategory,
|
||||
type InsertDocumentTemplateCategory,
|
||||
type DocumentTemplateFolder,
|
||||
type InsertDocumentTemplateFolder,
|
||||
type DocumentFolderMapping,
|
||||
type InsertDocumentFolderMapping,
|
||||
type DocumentTemplate,
|
||||
type InsertDocumentTemplate,
|
||||
type TemplateVariable,
|
||||
@@ -25,9 +31,9 @@ import {
|
||||
type InsertDocumentSignature,
|
||||
} from "@shared/schema";
|
||||
import { db } from "../db";
|
||||
import { eq, and, desc, asc, sql } from "drizzle-orm";
|
||||
import { eq, and, desc, asc, sql, isNull } from "drizzle-orm";
|
||||
|
||||
// ── Document Template Categories ─────────────────────────────────────────────
|
||||
// ── Document Template Categories (legacy) ────────────────────────────────────
|
||||
|
||||
export async function listDocumentTemplateCategories(organizationId: number): Promise<DocumentTemplateCategory[]> {
|
||||
return db
|
||||
@@ -69,12 +75,101 @@ export async function deleteDocumentTemplateCategory(id: number, organizationId:
|
||||
.where(and(eq(documentTemplateCategories.id, id), eq(documentTemplateCategories.organizationId, organizationId)));
|
||||
}
|
||||
|
||||
// ── Document Template Folders ─────────────────────────────────────────────────
|
||||
|
||||
export async function listDocumentTemplateFolders(organizationId: number): Promise<DocumentTemplateFolder[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(documentTemplateFolders)
|
||||
.where(eq(documentTemplateFolders.organizationId, organizationId))
|
||||
.orderBy(asc(documentTemplateFolders.position), asc(documentTemplateFolders.name));
|
||||
}
|
||||
|
||||
export async function getDocumentTemplateFolder(id: number, organizationId: number): Promise<DocumentTemplateFolder | undefined> {
|
||||
const [folder] = await db
|
||||
.select()
|
||||
.from(documentTemplateFolders)
|
||||
.where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId)));
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function createDocumentTemplateFolder(data: InsertDocumentTemplateFolder): Promise<DocumentTemplateFolder> {
|
||||
const [folder] = await db.insert(documentTemplateFolders).values(data).returning();
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function updateDocumentTemplateFolder(
|
||||
id: number,
|
||||
organizationId: number,
|
||||
updates: Partial<DocumentTemplateFolder>,
|
||||
): Promise<DocumentTemplateFolder | undefined> {
|
||||
const [folder] = await db
|
||||
.update(documentTemplateFolders)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId)))
|
||||
.returning();
|
||||
return folder;
|
||||
}
|
||||
|
||||
export async function deleteDocumentTemplateFolder(id: number, organizationId: number): Promise<void> {
|
||||
await db
|
||||
.delete(documentTemplateFolders)
|
||||
.where(and(eq(documentTemplateFolders.id, id), eq(documentTemplateFolders.organizationId, organizationId)));
|
||||
}
|
||||
|
||||
// ── Document Folder Mappings ──────────────────────────────────────────────────
|
||||
|
||||
export async function listDocumentFolderMappings(folderId: number): Promise<DocumentFolderMapping[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(documentFolderMappings)
|
||||
.where(eq(documentFolderMappings.folderId, folderId))
|
||||
.orderBy(asc(documentFolderMappings.sortOrder), asc(documentFolderMappings.id));
|
||||
}
|
||||
|
||||
export async function getDocumentFolderMapping(id: number, folderId: number): Promise<DocumentFolderMapping | undefined> {
|
||||
const [mapping] = await db
|
||||
.select()
|
||||
.from(documentFolderMappings)
|
||||
.where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId)));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function createDocumentFolderMapping(data: InsertDocumentFolderMapping): Promise<DocumentFolderMapping> {
|
||||
const [mapping] = await db.insert(documentFolderMappings).values(data).returning();
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function updateDocumentFolderMapping(
|
||||
id: number,
|
||||
folderId: number,
|
||||
updates: Partial<DocumentFolderMapping>,
|
||||
): Promise<DocumentFolderMapping | undefined> {
|
||||
const [mapping] = await db
|
||||
.update(documentFolderMappings)
|
||||
.set(updates)
|
||||
.where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId)))
|
||||
.returning();
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function deleteDocumentFolderMapping(id: number, folderId: number): Promise<void> {
|
||||
await db
|
||||
.delete(documentFolderMappings)
|
||||
.where(and(eq(documentFolderMappings.id, id), eq(documentFolderMappings.folderId, folderId)));
|
||||
}
|
||||
|
||||
export async function deleteDocumentFolderMappingsByFolder(folderId: number): Promise<void> {
|
||||
await db.delete(documentFolderMappings).where(eq(documentFolderMappings.folderId, folderId));
|
||||
}
|
||||
|
||||
// ── Document Templates ───────────────────────────────────────────────────────
|
||||
|
||||
export interface TemplateListFilters {
|
||||
organizationId: number;
|
||||
status?: string;
|
||||
categoryId?: number;
|
||||
folderId?: number;
|
||||
formId?: number;
|
||||
}
|
||||
|
||||
@@ -82,6 +177,13 @@ export async function listDocumentTemplates(filters: TemplateListFilters): Promi
|
||||
const conditions = [eq(documentTemplates.organizationId, filters.organizationId)];
|
||||
if (filters.status) conditions.push(eq(documentTemplates.status, filters.status));
|
||||
if (filters.categoryId !== undefined) conditions.push(eq(documentTemplates.categoryId, filters.categoryId));
|
||||
if (filters.folderId !== undefined) {
|
||||
if (filters.folderId === null) {
|
||||
conditions.push(isNull(documentTemplates.folderId));
|
||||
} else {
|
||||
conditions.push(eq(documentTemplates.folderId, filters.folderId));
|
||||
}
|
||||
}
|
||||
if (filters.formId !== undefined) conditions.push(eq(documentTemplates.formId, filters.formId));
|
||||
|
||||
return db
|
||||
|
||||
Reference in New Issue
Block a user