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

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

401 lines
16 KiB
TypeScript

import { Router } from "express";
import { z } from 'zod';
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { insertBookmarkSchema, insertBookmarkFolderSchema, createBookmarkSchema } from "@shared/schema";
function normalizeBookmarkUrl(url: string, requestHostname: string): string {
if (!url || url.startsWith('/')) return url;
try {
const parsed = new URL(url);
if (parsed.hostname === requestHostname) {
return parsed.pathname + parsed.search + parsed.hash;
}
} catch {
// not a valid absolute URL
}
return url;
}
const reorderItemSchema = z.object({
items: z.array(z.object({
id: z.number().int().positive(),
position: z.number().int().min(0),
})).min(1),
});
type ImportNode = { name: string; url?: string; children?: ImportNode[] };
const importUrlSchema = z.string().max(1000).refine(
(val) => {
if (val.startsWith('/')) return true;
try { new URL(val); return true; } catch { return false; }
},
{ message: "Укажите путь (/tasks) или полный URL (https://...)" }
);
const importNodeSchema: z.ZodType<ImportNode> = z.lazy(() =>
z.object({
name: z.string().min(1),
url: importUrlSchema.optional(),
children: z.array(importNodeSchema).optional(),
})
);
const importBodySchema = z.union([
z.array(importNodeSchema),
z.object({ bookmarks: z.array(importNodeSchema) }),
]);
export function registerBookmarkRoutes(router: Router): void {
// Get all bookmarks for organization
router.get('/api/bookmarks',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const bookmarks = await storage.getBookmarksByOrganization(req.organizationId!, req.user!.id);
res.json({ success: true, bookmarks });
} catch (error) {
console.error('Get bookmarks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при загрузке закладок' });
}
}
);
// Get bookmarks by folder
router.get('/api/bookmarks/by-folder/:folderId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const folderId = req.params.folderId === 'null' ? null : parseInt(req.params.folderId);
if (folderId !== null && isNaN(folderId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID папки' });
}
const bookmarks = await storage.getBookmarksByFolder(folderId, req.organizationId!, req.user!.id);
res.json({ success: true, bookmarks });
} catch (error) {
console.error('Get bookmarks by folder error:', error);
res.status(500).json({ success: false, error: 'Ошибка при загрузке закладок папки' });
}
}
);
// Export all bookmarks as nested JSON tree
router.get('/api/bookmarks/export',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const allFolders = await storage.getBookmarkFoldersByOrganization(req.organizationId!, req.user!.id);
const allBookmarks = await storage.getBookmarksByOrganization(req.organizationId!, req.user!.id);
type ExportNode = { name: string; url?: string; children?: ExportNode[] };
function resolveBookmarkUrl(bm: typeof allBookmarks[number]): string | undefined {
switch (bm.type) {
case 'form': return bm.targetId ? `/forms/${bm.targetId}/tasks` : undefined;
case 'task': return bm.targetId
? (bm.formId ? `/forms/${bm.formId}/tasks/${bm.targetId}` : `/tasks/${bm.targetId}`)
: undefined;
case 'kanban': return bm.targetId ? `/kanban/${bm.targetId}` : undefined;
case 'report': return bm.targetId ? `/reports/${bm.targetId}` : undefined;
case 'dashboard': return '/';
case 'custom_url': return bm.url ?? undefined;
default: return bm.url ?? undefined;
}
}
function bookmarkToNode(bm: typeof allBookmarks[number]): ExportNode {
return { name: bm.name, url: resolveBookmarkUrl(bm) };
}
function buildFolderTree(parentId: number | null): ExportNode[] {
const nodes: ExportNode[] = [];
const childFolders = allFolders
.filter(f => f.parentId === parentId)
.sort((a, b) => a.position - b.position);
for (const folder of childFolders) {
const node: ExportNode = { name: folder.name, children: [] };
const folderBookmarks = allBookmarks
.filter(b => b.folderId === folder.id)
.sort((a, b) => a.position - b.position);
for (const bm of folderBookmarks) {
node.children!.push(bookmarkToNode(bm));
}
node.children!.push(...buildFolderTree(folder.id));
nodes.push(node);
}
return nodes;
}
const rootBookmarks = allBookmarks
.filter(b => b.folderId === null)
.sort((a, b) => a.position - b.position)
.map(bookmarkToNode);
const tree: ExportNode[] = [...rootBookmarks, ...buildFolderTree(null)];
res.json({ success: true, bookmarks: tree });
} catch (error) {
console.error('Export bookmarks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при экспорте закладок' });
}
}
);
// Import bookmarks from nested JSON tree (additive)
router.post('/api/bookmarks/import',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const parsed = importBodySchema.safeParse(req.body);
if (!parsed.success) {
const firstError = parsed.error.errors[0];
const details = parsed.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join('; ');
const isUrlError = parsed.error.errors.some(e => e.path.includes('url'));
const message = isUrlError
? `Некорректный URL в закладке: ${firstError?.message ?? details}`
: `Некорректная структура файла импорта: ${details}`;
return res.status(400).json({ success: false, error: message, details: parsed.error.flatten() });
}
let foldersCreated = 0;
let bookmarksCreated = 0;
const allFolders = await storage.getBookmarkFoldersByOrganization(req.organizationId!, req.user!.id);
const allBookmarks = await storage.getBookmarksByOrganization(req.organizationId!, req.user!.id);
const folderMaxPos = new Map<string, number>();
for (const f of allFolders) {
const key = String(f.parentId ?? 'null');
folderMaxPos.set(key, Math.max(folderMaxPos.get(key) ?? -1, f.position));
}
const bmMaxPos = new Map<string, number>();
for (const b of allBookmarks) {
const key = String(b.folderId ?? 'null');
bmMaxPos.set(key, Math.max(bmMaxPos.get(key) ?? -1, b.position));
}
function nextFolderPos(parentId: number | null): number {
const key = String(parentId ?? 'null');
const cur = folderMaxPos.get(key) ?? -1;
folderMaxPos.set(key, cur + 1);
return cur + 1;
}
function nextBmPos(folderId: number | null): number {
const key = String(folderId ?? 'null');
const cur = bmMaxPos.get(key) ?? -1;
bmMaxPos.set(key, cur + 1);
return cur + 1;
}
async function importNodes(nodes: ImportNode[], parentFolderId: number | null): Promise<void> {
for (const node of nodes) {
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isFolder = !node.url || hasChildren;
if (isFolder) {
const rawImportFolder = {
name: node.name,
organizationId: req.organizationId!,
userId: req.user!.id,
createdBy: req.user!.id,
parentId: parentFolderId,
position: nextFolderPos(parentFolderId),
};
const parsedImportFolder = insertBookmarkFolderSchema.parse(rawImportFolder);
const folder = await storage.createBookmarkFolder(parsedImportFolder);
foldersCreated++;
if (Array.isArray(node.children)) {
await importNodes(node.children, folder.id);
}
} else {
const rawImportBookmark = {
name: node.name,
url: node.url,
type: 'custom_url',
organizationId: req.organizationId!,
userId: req.user!.id,
createdBy: req.user!.id,
folderId: parentFolderId,
position: nextBmPos(parentFolderId),
};
const parsedImportBookmark = insertBookmarkSchema.parse(rawImportBookmark);
await storage.createBookmark(parsedImportBookmark);
bookmarksCreated++;
}
}
}
const nodes = Array.isArray(parsed.data) ? parsed.data : parsed.data.bookmarks;
await importNodes(nodes, null);
res.json({
success: true,
message: `Импортировано: ${foldersCreated} папок, ${bookmarksCreated} закладок`,
foldersCreated,
bookmarksCreated,
});
} catch (error) {
if (error instanceof z.ZodError) {
const summary = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join('; ');
return res.status(400).json({
success: false,
error: `Некорректные данные закладки или папки: ${summary}`,
details: error.flatten(),
});
}
console.error('Import bookmarks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при импорте закладок' });
}
}
);
// Reorder bookmarks
router.post('/api/bookmarks/reorder',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const parsed = reorderItemSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ success: false, error: 'Некорректный формат данных' });
}
await storage.batchUpdateBookmarkPositions(parsed.data.items, req.organizationId!, req.user!.id);
res.json({ success: true });
} catch (error) {
console.error('Reorder bookmarks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при изменении порядка закладок' });
}
}
);
// Normalize bookmark positions
router.post('/api/bookmarks/normalize-positions',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
await storage.normalizeBookmarkPositions(req.organizationId!, req.user!.id);
res.json({ success: true, message: 'Позиции закладок нормализованы' });
} catch (error) {
console.error('Normalize bookmark positions error:', error);
res.status(500).json({ success: false, error: 'Ошибка при нормализации позиций закладок' });
}
}
);
// Create bookmark
router.post('/api/bookmarks',
authenticateToken,
tenantIsolation,
validateRequest(createBookmarkSchema),
async (req: AuthenticatedRequest, res) => {
try {
if (req.body.folderId) {
const folder = await storage.getBookmarkFolder(req.body.folderId, req.organizationId!, req.user!.id);
if (!folder) {
return res.status(400).json({ success: false, error: 'Папка не найдена или не принадлежит вам' });
}
}
const rawBookmarkData = {
...req.body,
url: req.body.url ? normalizeBookmarkUrl(req.body.url, req.hostname) : req.body.url,
organizationId: req.organizationId!,
userId: req.user!.id,
createdBy: req.user!.id
};
const parsedBookmark = insertBookmarkSchema.safeParse(rawBookmarkData);
if (!parsedBookmark.success) {
return res.status(400).json({
success: false,
error: 'Некорректные данные закладки',
details: parsedBookmark.error.flatten()
});
}
const createdBookmark = await storage.createBookmark(parsedBookmark.data);
res.status(201).json({ success: true, message: 'Закладка создана', bookmark: createdBookmark });
} catch (error: any) {
console.error('Create bookmark error:', error);
if (error.code === '23503') {
if (error.detail?.includes('form_id')) {
return res.status(400).json({ success: false, error: 'Указанная форма не существует. Проверьте ID формы.' });
}
if (error.detail?.includes('folder_id')) {
return res.status(400).json({ success: false, error: 'Указанная папка не существует. Проверьте ID папки.' });
}
}
res.status(500).json({ success: false, error: 'Ошибка при создании закладки' });
}
}
);
// Update bookmark
router.put('/api/bookmarks/:id',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const bookmarkId = parseInt(req.params.id);
if (isNaN(bookmarkId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID закладки' });
}
const existingBookmark = await storage.getBookmark(bookmarkId, req.organizationId!, req.user!.id);
if (!existingBookmark) {
return res.status(404).json({ success: false, error: 'Закладка не найдена' });
}
const { userId: _uB, createdBy: _cbB, organizationId: _oB, id: _idB, ...bodyUpdates } = req.body;
if (bodyUpdates.folderId) {
const folder = await storage.getBookmarkFolder(bodyUpdates.folderId, req.organizationId!, req.user!.id);
if (!folder) {
return res.status(400).json({ success: false, error: 'Папка не найдена или не принадлежит вам' });
}
}
const updateData = bodyUpdates.url
? { ...bodyUpdates, url: normalizeBookmarkUrl(bodyUpdates.url, req.hostname) }
: bodyUpdates;
const updatedBookmark = await storage.updateBookmark(
bookmarkId, req.organizationId!, updateData, req.user!.id
);
res.json({ success: true, message: 'Закладка обновлена', bookmark: updatedBookmark });
} catch (error) {
console.error('Update bookmark error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении закладки' });
}
}
);
// Delete bookmark
router.delete('/api/bookmarks/:id',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const bookmarkId = parseInt(req.params.id);
if (isNaN(bookmarkId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID закладки' });
}
const existingBookmark = await storage.getBookmark(bookmarkId, req.organizationId!, req.user!.id);
if (!existingBookmark) {
return res.status(404).json({ success: false, error: 'Закладка не найдена' });
}
await storage.deleteBookmark(bookmarkId, req.organizationId!, req.user!.id);
res.json({ success: true, message: 'Закладка удалена' });
} catch (error) {
console.error('Delete bookmark error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении закладки' });
}
}
);
}