- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
185 lines
6.2 KiB
TypeScript
185 lines
6.2 KiB
TypeScript
import fssync from 'fs';
|
||
import fs from 'fs/promises';
|
||
import path from 'path';
|
||
import {
|
||
organizationAssets,
|
||
fileUploads,
|
||
type OrganizationAsset,
|
||
type InsertOrganizationAsset,
|
||
} from "@shared/schema";
|
||
import * as storage from "../storage/documents.storage";
|
||
import { uploadToS3, streamFromS3, isS3Enabled, S3_BUCKET } from "../utils/s3";
|
||
import { uploadsDir } from "../utils/upload";
|
||
import { db } from "../db";
|
||
import { eq, and } from "drizzle-orm";
|
||
|
||
export interface AssetListFilters {
|
||
organizationId: number;
|
||
type?: string;
|
||
isActive?: boolean;
|
||
}
|
||
|
||
export interface MulterFile {
|
||
path: string;
|
||
originalname: string;
|
||
mimetype: string;
|
||
size: number;
|
||
}
|
||
|
||
const MIME_BY_EXT: Record<string, string> = {
|
||
png: 'image/png',
|
||
jpg: 'image/jpeg',
|
||
jpeg: 'image/jpeg',
|
||
gif: 'image/gif',
|
||
webp: 'image/webp',
|
||
svg: 'image/svg+xml',
|
||
};
|
||
|
||
function getMimeType(fileKeyOrName: string, fallback?: string): string {
|
||
const ext = path.extname(fileKeyOrName).slice(1).toLowerCase();
|
||
return fallback || MIME_BY_EXT[ext] || 'image/png';
|
||
}
|
||
|
||
export class AssetService {
|
||
// ── CRUD ───────────────────────────────────────────────────────────────────
|
||
|
||
async list(filters: AssetListFilters): Promise<OrganizationAsset[]> {
|
||
return storage.listOrganizationAssets(filters);
|
||
}
|
||
|
||
async getById(id: number, organizationId: number): Promise<OrganizationAsset | undefined> {
|
||
return storage.getOrganizationAsset(id, organizationId);
|
||
}
|
||
|
||
async create(data: InsertOrganizationAsset & { file?: MulterFile }): Promise<OrganizationAsset> {
|
||
let fileKey = data.fileKey;
|
||
|
||
if (data.file) {
|
||
const upload = await this.uploadAssetFile(data.file);
|
||
fileKey = upload.key;
|
||
}
|
||
|
||
if (!fileKey) {
|
||
throw new Error('Не указан файл для актива');
|
||
}
|
||
|
||
return storage.createOrganizationAsset({
|
||
...data,
|
||
fileKey,
|
||
});
|
||
}
|
||
|
||
async update(
|
||
id: number,
|
||
organizationId: number,
|
||
updates: Partial<InsertOrganizationAsset> & { file?: MulterFile },
|
||
): Promise<OrganizationAsset | undefined> {
|
||
let fileKey = updates.fileKey;
|
||
|
||
if (updates.file) {
|
||
const existing = await storage.getOrganizationAsset(id, organizationId);
|
||
if (existing?.fileKey) {
|
||
await this.deleteAssetFile(existing.fileKey);
|
||
}
|
||
const upload = await this.uploadAssetFile(updates.file);
|
||
fileKey = upload.key;
|
||
}
|
||
|
||
return storage.updateOrganizationAsset(id, organizationId, {
|
||
...updates,
|
||
...(fileKey ? { fileKey } : {}),
|
||
});
|
||
}
|
||
|
||
async delete(id: number, organizationId: number): Promise<void> {
|
||
const asset = await storage.getOrganizationAsset(id, organizationId);
|
||
if (asset?.fileKey) {
|
||
await this.deleteAssetFile(asset.fileKey);
|
||
}
|
||
await storage.deleteOrganizationAsset(id, organizationId);
|
||
}
|
||
|
||
// ── File handling ──────────────────────────────────────────────────────────
|
||
|
||
private async uploadAssetFile(file: MulterFile): Promise<{ url: string; key: string }> {
|
||
const buffer = await fs.readFile(file.path);
|
||
const name = file.originalname;
|
||
const mimeType = file.mimetype || getMimeType(name);
|
||
|
||
if (isS3Enabled) {
|
||
const result = await uploadToS3(buffer, name, mimeType);
|
||
await fs.unlink(file.path).catch(() => {});
|
||
return result;
|
||
}
|
||
|
||
// Локальный режим: копируем файл из tmp в uploads/
|
||
const ext = path.extname(name);
|
||
const key = `${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`;
|
||
const dest = path.join(uploadsDir, key);
|
||
await fs.copyFile(file.path, dest);
|
||
await fs.unlink(file.path).catch(() => {});
|
||
return { url: `/api/files/${key}`, key };
|
||
}
|
||
|
||
private async deleteAssetFile(fileKey: string): Promise<void> {
|
||
if (isS3Enabled) {
|
||
const { deleteFromS3 } = await import("../utils/s3.js");
|
||
await deleteFromS3(fileKey);
|
||
} else {
|
||
const filePath = path.join(uploadsDir, path.basename(fileKey));
|
||
await fs.unlink(filePath).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// ── Base64 loading ─────────────────────────────────────────────────────────
|
||
|
||
async loadAsBase64(fileKey: string): Promise<string | null> {
|
||
const mimeType = getMimeType(fileKey);
|
||
|
||
if (isS3Enabled) {
|
||
const streamResult = await streamFromS3(fileKey);
|
||
if (!streamResult?.body) return null;
|
||
const chunks: Buffer[] = [];
|
||
for await (const chunk of streamResult.body as any) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
}
|
||
const buffer = Buffer.concat(chunks);
|
||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||
}
|
||
|
||
const filePath = path.join(uploadsDir, path.basename(fileKey));
|
||
try {
|
||
const buffer = await fs.readFile(filePath);
|
||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async loadByIdAsBase64(assetId: number, organizationId: number): Promise<string | null> {
|
||
const asset = await storage.getOrganizationAsset(assetId, organizationId);
|
||
if (!asset?.fileKey) return null;
|
||
return this.loadAsBase64(asset.fileKey);
|
||
}
|
||
|
||
async getAssetsForGeneration(
|
||
placements: Array<{ assetId: number; position: string; width?: number; height?: number; opacity?: number }>,
|
||
organizationId: number,
|
||
): Promise<Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }>> {
|
||
const result: Array<{ assetId: number; base64: string; position: string; width?: number; height?: number; opacity?: number }> = [];
|
||
for (const placement of placements) {
|
||
const base64 = await this.loadByIdAsBase64(placement.assetId, organizationId);
|
||
if (!base64) continue;
|
||
result.push({
|
||
assetId: placement.assetId,
|
||
base64,
|
||
position: placement.position,
|
||
width: placement.width,
|
||
height: placement.height,
|
||
opacity: placement.opacity,
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
}
|