feat(documents): папки шаблонов, маппинги и поле document_template; fix(batch): атомарное создание задач, idempotency и массовое удаление в реестре

This commit is contained in:
2026-07-08 13:33:14 +03:00
parent fc8e85832c
commit 6826d82850
34 changed files with 3770 additions and 467 deletions

View File

@@ -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}`);
});