feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+3 -9
View File
@@ -5,8 +5,6 @@ const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const mindmapRoutes = require('./routes/mindmap');
const syncRoutes = require('./routes/sync');
const authRoutes = require('./routes/auth');
const { errorHandler } = require('./middleware/errorHandler');
@@ -37,8 +35,8 @@ app.use(morgan('combined'));
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
});
@@ -46,8 +44,6 @@ app.get('/health', (req, res) => {
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/mindmaps', mindmapRoutes);
app.use('/api/sync', syncRoutes);
// Error handling
app.use(errorHandler);
@@ -58,7 +54,5 @@ app.use((req, res) => {
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Nanaxe API Server running on port ${PORT}`);
console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`);
console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`);
console.log(`🚀 XFlow API Server running on port ${PORT}`);
});
-266
View File
@@ -1,266 +0,0 @@
const express = require('express');
const router = express.Router();
const fs = require('fs-extra');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { authenticateToken } = require('../middleware/auth');
const MarkdownConverter = require('../utils/markdownConverter');
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
// Ensure storage directory exists
fs.ensureDirSync(STORAGE_PATH);
/**
* GET /api/mindmaps - List all mindmaps
*/
router.get('/', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const userDir = path.join(STORAGE_PATH, userId);
await fs.ensureDir(userDir);
const files = await fs.readdir(userDir);
const mindmaps = [];
for (const file of files) {
if (file.endsWith('.md')) {
const filePath = path.join(userDir, file);
const content = await fs.readFile(filePath, 'utf-8');
const mindmap = MarkdownConverter.fromMarkdown(content);
mindmaps.push({
id: mindmap.id,
title: mindmap.title,
description: mindmap.description,
nodeCount: mindmap.nodes.length,
updatedAt: mindmap.updatedAt,
filename: file
});
}
}
// Sort by updatedAt desc
mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
res.json({
count: mindmaps.length,
mindmaps
});
} catch (error) {
console.error('Error listing mindmaps:', error);
res.status(500).json({ error: 'Failed to list mindmaps' });
}
});
/**
* GET /api/mindmaps/:id - Get single mindmap
*/
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
if (!await fs.pathExists(filePath)) {
return res.status(404).json({ error: 'Mindmap not found' });
}
const content = await fs.readFile(filePath, 'utf-8');
const mindmap = MarkdownConverter.fromMarkdown(content, id);
res.json(mindmap);
} catch (error) {
console.error('Error getting mindmap:', error);
res.status(500).json({ error: 'Failed to get mindmap' });
}
});
/**
* POST /api/mindmaps - Create new mindmap
*/
router.post('/', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { title, description, nodes = [], settings = {} } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
const id = uuidv4();
const now = new Date().toISOString();
const mindmap = {
id,
title,
description: description || '',
nodes: nodes.length > 0 ? nodes : [{
id: uuidv4(),
title: 'Root',
level: 0,
parentId: null,
createdAt: now,
updatedAt: now
}],
settings,
createdAt: now,
updatedAt: now
};
const userDir = path.join(STORAGE_PATH, userId);
await fs.ensureDir(userDir);
const markdown = MarkdownConverter.toMarkdown(mindmap);
const filePath = path.join(userDir, `${id}.md`);
await fs.writeFile(filePath, markdown, 'utf-8');
res.status(201).json(mindmap);
} catch (error) {
console.error('Error creating mindmap:', error);
res.status(500).json({ error: 'Failed to create mindmap' });
}
});
/**
* PUT /api/mindmaps/:id - Update mindmap
*/
router.put('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const updates = req.body;
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
if (!await fs.pathExists(filePath)) {
return res.status(404).json({ error: 'Mindmap not found' });
}
// Read existing
const content = await fs.readFile(filePath, 'utf-8');
const existing = MarkdownConverter.fromMarkdown(content, id);
// Merge updates
const updated = {
...existing,
...updates,
id, // Preserve ID
updatedAt: new Date().toISOString()
};
// Save
const markdown = MarkdownConverter.toMarkdown(updated);
await fs.writeFile(filePath, markdown, 'utf-8');
res.json(updated);
} catch (error) {
console.error('Error updating mindmap:', error);
res.status(500).json({ error: 'Failed to update mindmap' });
}
});
/**
* DELETE /api/mindmaps/:id - Delete mindmap
*/
router.delete('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
if (!await fs.pathExists(filePath)) {
return res.status(404).json({ error: 'Mindmap not found' });
}
await fs.remove(filePath);
res.json({ message: 'Mindmap deleted successfully' });
} catch (error) {
console.error('Error deleting mindmap:', error);
res.status(500).json({ error: 'Failed to delete mindmap' });
}
});
/**
* GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown
*/
router.get('/:id/markdown', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
if (!await fs.pathExists(filePath)) {
return res.status(404).json({ error: 'Mindmap not found' });
}
const content = await fs.readFile(filePath, 'utf-8');
res.setHeader('Content-Type', 'text/markdown');
res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`);
res.send(content);
} catch (error) {
console.error('Error getting markdown:', error);
res.status(500).json({ error: 'Failed to get markdown' });
}
});
/**
* POST /api/mindmaps/:id/markdown - Update mindmap from markdown
*/
router.post('/:id/markdown', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const { markdown } = req.body;
if (!markdown) {
return res.status(400).json({ error: 'Markdown content is required' });
}
const userDir = path.join(STORAGE_PATH, userId);
await fs.ensureDir(userDir);
const filePath = path.join(userDir, `${id}.md`);
await fs.writeFile(filePath, markdown, 'utf-8');
// Parse and return
const mindmap = MarkdownConverter.fromMarkdown(markdown, id);
res.json(mindmap);
} catch (error) {
console.error('Error saving markdown:', error);
res.status(500).json({ error: 'Failed to save markdown' });
}
});
/**
* GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format
*/
router.get('/:id/markmap', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const filePath = path.join(STORAGE_PATH, userId, `${id}.md`);
if (!await fs.pathExists(filePath)) {
return res.status(404).json({ error: 'Mindmap not found' });
}
const content = await fs.readFile(filePath, 'utf-8');
const mindmap = MarkdownConverter.fromMarkdown(content, id);
const markmap = MarkdownConverter.toMarkmap(mindmap);
res.setHeader('Content-Type', 'text/markdown');
res.send(markmap);
} catch (error) {
console.error('Error getting markmap:', error);
res.status(500).json({ error: 'Failed to get markmap' });
}
});
module.exports = router;
-268
View File
@@ -1,268 +0,0 @@
const express = require('express');
const router = express.Router();
const fs = require('fs-extra');
const path = require('path');
const simpleGit = require('simple-git');
const { authenticateToken } = require('../middleware/auth');
const MarkdownConverter = require('../utils/markdownConverter');
const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps';
const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000';
const GITEA_TOKEN = process.env.GITEA_TOKEN;
/**
* POST /api/sync/gitea/push - Push mindmaps to Gitea repository
*/
router.post('/gitea/push', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { repoName, commitMessage } = req.body;
const userDir = path.join(STORAGE_PATH, userId);
const repoDir = path.join(userDir, '.git-repo');
if (!await fs.pathExists(userDir)) {
return res.status(404).json({ error: 'No mindmaps found' });
}
// Initialize or open git repo
const git = simpleGit(repoDir);
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
await fs.ensureDir(repoDir);
await git.init();
// Configure git
await git.addConfig('user.email', req.user.email || 'user@nanaxe.app');
await git.addConfig('user.name', req.user.username || 'Nanaxe User');
}
// Copy all mindmaps to repo
const files = await fs.readdir(userDir);
for (const file of files) {
if (file.endsWith('.md')) {
await fs.copy(
path.join(userDir, file),
path.join(repoDir, file)
);
}
}
// Commit changes
await git.add('.');
const status = await git.status();
if (status.files.length === 0) {
return res.json({ message: 'No changes to commit', synced: false });
}
await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM');
// Push to Gitea if configured
if (GITEA_TOKEN) {
const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
try {
// Check if remote exists
const remotes = await git.getRemotes(true);
const originExists = remotes.some(r => r.name === 'origin');
if (!originExists) {
await git.addRemote('origin', remoteUrl);
}
await git.push('origin', 'master');
} catch (pushError) {
console.error('Push error:', pushError);
// Continue even if push fails - local commit is successful
}
}
res.json({
message: 'Mindmaps synced successfully',
synced: true,
filesCommitted: status.files.length,
commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM'
});
} catch (error) {
console.error('Error syncing to Gitea:', error);
res.status(500).json({ error: 'Failed to sync mindmaps' });
}
});
/**
* POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository
*/
router.post('/gitea/pull', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { repoName } = req.body;
const userDir = path.join(STORAGE_PATH, userId);
const repoDir = path.join(userDir, '.git-repo');
if (!GITEA_TOKEN) {
return res.status(400).json({ error: 'Gitea not configured' });
}
const git = simpleGit(repoDir);
if (!await fs.pathExists(path.join(repoDir, '.git'))) {
await fs.ensureDir(repoDir);
// Clone from Gitea
const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`;
await git.clone(cloneUrl, repoDir);
} else {
// Pull latest
await git.pull('origin', 'master');
}
// Copy files from repo to storage
const files = await fs.readdir(repoDir);
let importedCount = 0;
for (const file of files) {
if (file.endsWith('.md') && !file.startsWith('.')) {
await fs.copy(
path.join(repoDir, file),
path.join(userDir, file),
{ overwrite: true }
);
importedCount++;
}
}
res.json({
message: 'Mindmaps pulled successfully',
imported: importedCount
});
} catch (error) {
console.error('Error pulling from Gitea:', error);
res.status(500).json({ error: 'Failed to pull mindmaps' });
}
});
/**
* GET /api/sync/status - Get sync status
*/
router.get('/status', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const userDir = path.join(STORAGE_PATH, userId);
const repoDir = path.join(userDir, '.git-repo');
const status = {
hasLocalData: await fs.pathExists(userDir),
hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')),
giteaConfigured: !!GITEA_TOKEN,
lastSync: null,
pendingChanges: 0
};
if (status.hasGitRepo) {
const git = simpleGit(repoDir);
const gitStatus = await git.status();
status.pendingChanges = gitStatus.files.length;
// Get last commit date
try {
const log = await git.log({ maxCount: 1 });
if (log.latest) {
status.lastSync = log.latest.date;
}
} catch (e) {
// No commits yet
}
}
res.json(status);
} catch (error) {
console.error('Error getting sync status:', error);
res.status(500).json({ error: 'Failed to get sync status' });
}
});
/**
* POST /api/sync/export - Export all mindmaps as zip
*/
router.post('/export', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const userDir = path.join(STORAGE_PATH, userId);
if (!await fs.pathExists(userDir)) {
return res.status(404).json({ error: 'No mindmaps found' });
}
// Create temporary zip
const archiver = require('archiver');
const archive = archiver('zip', { zlib: { level: 9 } });
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"');
archive.pipe(res);
const files = await fs.readdir(userDir);
for (const file of files) {
if (file.endsWith('.md')) {
archive.file(path.join(userDir, file), { name: file });
}
}
await archive.finalize();
} catch (error) {
console.error('Error exporting mindmaps:', error);
res.status(500).json({ error: 'Failed to export mindmaps' });
}
});
/**
* POST /api/sync/import - Import mindmaps from files
*/
router.post('/import', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { files } = req.body; // Array of { filename, content }
if (!files || !Array.isArray(files)) {
return res.status(400).json({ error: 'Files array required' });
}
const userDir = path.join(STORAGE_PATH, userId);
await fs.ensureDir(userDir);
const results = {
imported: 0,
errors: []
};
for (const file of files) {
try {
if (!file.filename.endsWith('.md')) {
results.errors.push({ file: file.filename, error: 'Not a markdown file' });
continue;
}
// Validate markdown
const mindmap = MarkdownConverter.fromMarkdown(file.content);
// Save
const filePath = path.join(userDir, file.filename);
await fs.writeFile(filePath, file.content, 'utf-8');
results.imported++;
} catch (error) {
results.errors.push({ file: file.filename, error: error.message });
}
}
res.json(results);
} catch (error) {
console.error('Error importing mindmaps:', error);
res.status(500).json({ error: 'Failed to import mindmaps' });
}
});
module.exports = router;
-261
View File
@@ -1,261 +0,0 @@
/**
* Конвертер между MindMap форматом и Markdown (Obsidian-совместимый)
*
* Obsidian MindMap формат:
* - Заголовки (# ## ###) = уровни узлов
* - Списки (- *) = дочерние узлы
* - [[ссылки]] = внутренние связи
* - Теги #тег = метки узлов
*/
const YAML = require('yaml');
class MarkdownConverter {
/**
* Конвертирует MindMap из JSON в Markdown
* @param {Object} mindmap - Объект mindmap
* @returns {string} Markdown строка
*/
static toMarkdown(mindmap) {
const { id, title, description, nodes, settings = {} } = mindmap;
// Frontmatter с метаданными
const frontmatter = {
id,
title,
description,
createdAt: mindmap.createdAt,
updatedAt: mindmap.updatedAt,
...settings
};
let markdown = '---\n';
markdown += YAML.stringify(frontmatter);
markdown += '---\n\n';
// Заголовок mindmap
markdown += `# ${title}\n\n`;
if (description) {
markdown += `> ${description}\n\n`;
}
// Конвертируем узлы в иерархию
if (nodes && nodes.length > 0) {
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
for (const root of rootNodes) {
markdown += this._nodeToMarkdown(nodes, root, 0);
}
}
return markdown;
}
/**
* Рекурсивно конвертирует узел в Markdown
*/
static _nodeToMarkdown(allNodes, node, level) {
const indent = ' '.repeat(level);
let markdown = '';
// Заголовок или список в зависимости от уровня
if (level === 0) {
markdown += `## ${node.title}\n`;
} else if (level === 1) {
markdown += `### ${node.title}\n`;
} else if (level === 2) {
markdown += `#### ${node.title}\n`;
} else {
markdown += `${indent}- ${node.title}\n`;
}
// Добавляем метаданные узла
if (node.taskId) {
markdown += `${indent} - Task: [[${node.taskId}]]\n`;
}
if (node.color) {
markdown += `${indent} - Color: \`${node.color}\`\n`;
}
// Находим дочерние узлы
const children = allNodes.filter(n => n.parentId === node.id);
for (const child of children) {
markdown += this._nodeToMarkdown(allNodes, child, level + 1);
}
markdown += '\n';
return markdown;
}
/**
* Парсит Markdown в MindMap JSON
* @param {string} markdown - Markdown строка
* @returns {Object} Объект mindmap
*/
static fromMarkdown(markdown, existingId = null) {
const lines = markdown.split('\n');
// Парсим frontmatter
let frontmatter = {};
let contentStart = 0;
if (lines[0] === '---') {
const endIndex = lines.indexOf('---', 1);
if (endIndex !== -1) {
const yamlContent = lines.slice(1, endIndex).join('\n');
frontmatter = YAML.parse(yamlContent) || {};
contentStart = endIndex + 1;
}
}
// Парсим контент
const content = lines.slice(contentStart).join('\n');
const nodes = this._parseNodes(content);
return {
id: existingId || frontmatter.id || this._generateId(),
title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap',
description: frontmatter.description || '',
nodes,
settings: frontmatter.settings || {},
createdAt: frontmatter.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}
/**
* Парсит узлы из Markdown контента
*/
static _parseNodes(content) {
const lines = content.split('\n');
const nodes = [];
const nodeStack = []; // Стек для отслеживания родительских узлов
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Определяем уровень и тип
let level = 0;
let title = '';
let parentId = null;
// Заголовки H2-H4
const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/);
if (headingMatch) {
level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3
title = headingMatch[2].trim();
}
// Списки
else if (trimmed.match(/^[-*]\s+/)) {
const indent = line.search(/\S/);
level = Math.floor(indent / 2) + 3;
title = trimmed.replace(/^[-*]\s+/, '');
}
// Пропускаем H1 (это заголовок карты)
else if (trimmed.startsWith('# ')) {
continue;
}
else {
continue;
}
// Находим родителя
while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) {
nodeStack.pop();
}
if (nodeStack.length > 0) {
parentId = nodeStack[nodeStack.length - 1].id;
}
const node = {
id: this._generateId(),
title,
level,
parentId,
color: this._extractColor(trimmed),
taskId: this._extractTaskId(trimmed),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
nodes.push(node);
nodeStack.push({ id: node.id, level });
}
return nodes;
}
/**
* Извлекает цвет из строки
*/
static _extractColor(line) {
const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/);
return colorMatch ? `#${colorMatch[1]}` : null;
}
/**
* Извлекает ID задачи из ссылки
*/
static _extractTaskId(line) {
const taskMatch = line.match(/\[\[([^\]]+)\]\]/);
return taskMatch ? taskMatch[1] : null;
}
/**
* Извлекает заголовок из контента
*/
static _extractTitle(content) {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/**
* Генерирует уникальный ID
*/
static _generateId() {
return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
/**
* Конвертирует MindMap в формат Markmap (для визуализации)
* @param {Object} mindmap - Объект mindmap
* @returns {string} Markmap Markdown
*/
static toMarkmap(mindmap) {
const { title, nodes } = mindmap;
let markdown = `# ${title}\n\n`;
if (nodes && nodes.length > 0) {
const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId);
for (const root of rootNodes) {
markdown += this._nodeToMarkmap(nodes, root, 0);
}
}
return markdown;
}
/**
* Рекурсивно конвертирует узел в Markmap формат
*/
static _nodeToMarkmap(allNodes, node, level) {
const indent = ' '.repeat(level);
let markdown = `${indent}- ${node.title}\n`;
const children = allNodes.filter(n => n.parentId === node.id);
for (const child of children) {
markdown += this._nodeToMarkmap(allNodes, child, level + 1);
}
return markdown;
}
}
module.exports = MarkdownConverter;