- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
401 lines
16 KiB
TypeScript
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: 'Ошибка при удалении закладки' });
|
|
}
|
|
}
|
|
);
|
|
}
|