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 = 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(); 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(); 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 { 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: 'Ошибка при удалении закладки' }); } } ); }