feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI

This commit is contained in:
XFlow Deploy
2026-09-28 08:57:32 +05:00
parent edcd30c829
commit 0837b3f612
204 changed files with 24616 additions and 39 deletions
@@ -0,0 +1,18 @@
import { requireAuth } from '~/server/utils/auth'
import { getUserAchievements, achievements } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// Calculate stats
const userAchievements = getUserAchievements(user.id)
const totalCompleted = userAchievements.filter((a: any) => a.id?.startsWith('completed-')).length
return {
achievements: userAchievements,
stats: {
total: userAchievements.length,
completedTasks: totalCompleted
}
}
})
+47
View File
@@ -0,0 +1,47 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
console.log(`[Admin API] Request from user: ${user.email}, Role: ${user.role}`)
if (user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin access required' })
}
// 1. Получаем список всех пользователей с их профилями
const usersList = await prisma.user.findMany({
include: { profile: true }
})
// 2. Получаем список всех канвасов
const canvasesList = await prisma.canvas.findMany({
select: {
id: true,
name: true,
nodes: true,
updatedAt: true,
ownerId: true
}
})
// 3. Считаем общее кол-во пушей (если есть таблица)
let pushCount = 0
try {
pushCount = await prisma.pushSubscription.count()
} catch (e) {}
return {
users: usersList,
canvases: canvasesList.map(c => ({
...c,
nodeCount: (c.nodes as any[])?.length || 0
})),
stats: {
users: usersList.length,
canvases: canvasesList.length,
nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0),
pushes: pushCount
}
}
})
+170
View File
@@ -0,0 +1,170 @@
import { requireAuth } from '~/server/utils/auth'
import { generateAIResponse } from '~/server/utils/ai'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { action, text, model } = body
if (!action || !text) {
throw createError({ statusCode: 400, message: 'Action and text are required' })
}
console.log(`[AI Coach] action=${action} userId=${user.id}`)
try {
if (action === 'smart') {
const systemPrompt = `You are an expert executive coach. Your job is to transform any vague goal or statement into a high-quality, fully detailed S.M.A.R.T. goal. Keep your response in Russian.
Use Markdown headers for each element: Specific (Конкретная), Measurable (Измеримая), Achievable (Достижимая), Relevant (Значимая), Time-bound (Ограниченная во времени).
For Measurable, include a clear checklist with markdown checkboxes (- [ ]) for metrics. At the end, write a brief, inspiring summary.`
const prompt = `Пожалуйста, преобразуй следующую цель в формат S.M.A.R.T.
Исходная цель: "${text}"`
const result = await generateAIResponse(prompt, systemPrompt, model)
return { success: true, result }
} else if (action === 'grow') {
const systemPrompt = `You are a professional business coach. Your job is to perform a structured coaching analysis of the user's problem or goal using the G.R.O.W. framework. Keep all content in Russian.
You must return the response strictly as a JSON object with the following keys:
{
"goal": "Markdown text. Description of the reformulated Goal (G) starting with # 🎯 Цель (Goal).\\n\\nAnswer these questions:\\n- Какой именно цели я хочу достичь?\\n- Какой результат я хочу получить?\\n- Почему достижение этой цели важно?",
"options": "Markdown text. Options & Obstacles (O) starting with # 🚧 Варианты (Options).\\n\\nAnswer these questions:\\n- Что именно я могу изменить?\\n- Какие ресурсы мне нужны?\\n- Кто мне может помочь?\\n- А что еще?\\nInclude a checkbox list (- [ ]).",
"reality": "Markdown text. Description of the current Situation/Reality (R) starting with # 🔍 Реальность (Reality).\\n\\nAnswer these questions:\\n- Какова текущая ситуация?\\n- Какие препятствия я вижу?\\n- Что мне мешает начать?\\n- Какие у меня уже есть ресурсы?",
"wayForward": "Markdown text. Action Plan / Way Forward (W) starting with # 🏁 План действий (Way Forward).\\n\\nAnswer these questions:\\n- Какие самые важные действия необходимо предпринять?\\n- Что я сделаю в первую очередь? Какие сроки?\\nInclude a checkbox list (- [ ])."
}
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
const prompt = `Проведи GROW-коучинг анализ для следующей проблемы/цели: "${text}"`
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
// Robust JSON extraction
let jsonText = aiResponse.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.substring(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.substring(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.substring(0, jsonText.length - 3)
}
jsonText = jsonText.trim()
try {
const parsed = JSON.parse(jsonText)
return { success: true, result: parsed }
} catch (parseErr) {
console.error('Failed to parse AI G.R.O.W. JSON response:', aiResponse)
// Fallback: if JSON parsing fails, wrap the text into fields
return {
success: true,
result: {
goal: `# 🎯 Цель (Goal)\n\n${text}`,
reality: `# 🔍 Реальность (Reality)\n\nНе удалось автоматически разбить ответ AI на JSON. Вот оригинальный ответ:\n\n${aiResponse}`,
options: `# 🚧 Варианты\n\n- Рассмотреть альтернативные пути решения вручную`,
wayForward: `# 🏁 План действий\n\n- [ ] Детализировать шаги вручную`
}
}
}
} else if (action === 'dilts') {
const systemPrompt = `You are a professional life and business coach. Your job is to perform a structured analysis of the user's goal or situation using Robert Dilts' Logical Levels (Пирамида логических уровней Дилтса). Keep all content in Russian.
You must return the response strictly as a JSON object with the following keys:
{
"mission": "Markdown text starting with # 🌟 Миссия (Mission).\\n\\nAnswer these questions:\\n- Ради кого?\\n- Кому еще это важно?\\n(Уровень БЫТЬ)",
"identity": "Markdown text starting with # 👤 Идентичность (Identity).\\n\\nAnswer these questions:\\n- Кто я?",
"values": "Markdown text starting with # 💎 Ценности (Values).\\n\\nAnswer these questions:\\n- Почему это важно?",
"skills": "Markdown text starting with # 🧠 Навыки (Skills).\\n\\nAnswer these questions:\\n- Как? Каким образом?",
"actions": "Markdown text starting with # 🎬 Действия (Actions).\\n\\nAnswer these questions:\\n- Что делать? (Уровень ДЕЛАТЬ)",
"environment": "Markdown text starting with # 🌍 Окружение (Environment).\\n\\nAnswer these questions:\\n- Где? Когда? С кем? (Уровень ИМЕТЬ)"
}
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
const prompt = `Проведи коучинговый анализ по логическим уровням Дилтса для следующего запроса: "${text}"`
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
// Robust JSON extraction
let jsonText = aiResponse.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.substring(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.substring(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.substring(0, jsonText.length - 3)
}
jsonText = jsonText.trim()
try {
const parsed = JSON.parse(jsonText)
return { success: true, result: parsed }
} catch (parseErr) {
console.error('Failed to parse AI Dilts JSON response:', aiResponse)
// Fallback
return {
success: true,
result: {
mission: `# 🌟 Миссия (Mission)\n\nРади кого? Кому еще это важно? (Уровень БЫТЬ)\n\n${text}`,
identity: `# 👤 Идентичность (Identity)\n\nКто я?`,
values: `# 💎 Ценности (Values)\n\nПочему это важно?`,
skills: `# 🧠 Навыки (Skills)\n\nКак? Каким образом?`,
actions: `# 🎬 Действия (Actions)\n\nЧто делать? (Уровень ДЕЛАТЬ)\n\nНе удалось автоматически разбить ответ AI на JSON:\n\n${aiResponse}`,
environment: `# 🌍 Окружение (Environment)\n\nГде? Когда? С кем? (Уровень ИМЕТЬ)`
}
}
}
} else if (action === 'eisenhower') {
const systemPrompt = `You are a professional productivity expert. Your job is to analyze the user's tasks or list of ideas and prioritize them using the Eisenhower Matrix (Матрица Эйзенхауэра). Keep all content in Russian.
You must return the response strictly as a JSON object with the following keys:
{
"q1": "Markdown text starting with # 🔥 Важно и Срочно (Do First).\\n\\nIdentify tasks that are both urgent and important. Focus on crises, deadlines, and crucial problems.",
"q2": "Markdown text starting with # ⚡ Важно, но Несрочно (Schedule).\\n\\nIdentify tasks that are important but not urgent. Focus on long-term planning, personal growth, and relationships.",
"q3": "Markdown text starting with # 🧊 Неважно, но Срочно (Delegate).\\n\\nIdentify tasks that are urgent but not important. Focus on interruptions, minor meetings, and tasks that can be delegated.",
"q4": "Markdown text starting with # 💤 Неважно и Несрочно (Eliminate).\\n\\nIdentify tasks that are neither urgent nor important. Focus on time-wasting activities, distractions, and low-value tasks."
}
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
const prompt = `Распредели следующие задачи/цели по квадрантам Матрицы Эйзенхауэра: "${text}"`
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
// Robust JSON extraction
let jsonText = aiResponse.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.substring(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.substring(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.substring(0, jsonText.length - 3)
}
jsonText = jsonText.trim()
try {
const parsed = JSON.parse(jsonText)
return { success: true, result: parsed }
} catch (parseErr) {
console.error('Failed to parse AI Eisenhower JSON response:', aiResponse)
// Fallback
return {
success: true,
result: {
q1: `# 🔥 Важно и Срочно (Do First)\n\n- [ ] Срочные приоритетные задачи\n\n${text}`,
q2: `# ⚡ Важно, но Несрочно (Schedule)\n\n- [ ] Задачи планирования`,
q3: `# 🧊 Неважно, но Срочно (Delegate)\n\n- [ ] Задачи для делегирования\n\nНе удалось разобрать ответ ИИ:\n\n${aiResponse}`,
q4: `# 💤 Неважно и Несрочно (Eliminate)\n\n- [ ] Задачи, которые можно отложить`
}
}
}
} else {
throw createError({ statusCode: 400, message: `Unsupported action: ${action}` })
}
} catch (err: any) {
console.error('[AI Coach Error]:', err)
throw createError({
statusCode: 500,
message: err.message || 'AI coach generation failed'
})
}
})
+93
View File
@@ -0,0 +1,93 @@
import { generateToken } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
interface FirebaseUser {
localId: string
email: string
displayName?: string
photoUrl?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { idToken } = body
if (!idToken) {
throw createError({ statusCode: 400, message: 'Firebase ID token required' })
}
const config = useRuntimeConfig()
const apiKey = config.firebaseApiKey || config.public.firebaseApiKey
if (!apiKey) {
throw createError({ statusCode: 500, message: 'Firebase API key not configured' })
}
console.log('[Auth] Received idToken, length:', idToken?.length || 0)
console.log('[Auth] Using API key (masked):', apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : 'EMPTY')
let verifyRes: any
try {
verifyRes = await $fetch<any>(
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`,
{ method: 'POST', body: { idToken } }
)
} catch (err: any) {
console.error('[Auth] Firebase token verification failed:', err?.data || err?.message || err)
throw createError({ statusCode: 401, message: 'Invalid Firebase token' })
}
if (!verifyRes?.users?.[0]) {
throw createError({ statusCode: 401, message: 'Invalid Firebase token' })
}
const fbUser: FirebaseUser = verifyRes.users[0]
const email = fbUser.email
// Очищаем username: заменяем пробелы на дефисы и убираем спецсимволы
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
const userId = fbUser.localId
const user = await prisma.user.upsert({
where: { email },
update: {
username
// Мы не затираем профиль при каждом входе, но нам нужно его получить
},
create: {
id: userId,
email,
username,
password: 'firebase-managed',
role: 'user',
profile: {
create: {
displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
}
}
},
include: {
profile: true
}
})
const token = generateToken({
id: user.id,
email: user.email,
username: user.username,
displayName: user.profile?.displayName || user.username,
role: user.role
})
return {
message: 'Google sign-in successful',
token,
user: {
id: user.id,
email: user.email,
username: user.username,
role: user.role,
photoUrl: fbUser.photoUrl
}
}
})
+17
View File
@@ -0,0 +1,17 @@
import { verifyToken } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.split(' ')[1]
if (!token) {
return { valid: false, error: 'No token provided' }
}
try {
const decoded = verifyToken(token)
return { valid: true, user: decoded }
} catch {
return { valid: false, error: 'Invalid token' }
}
})
+34
View File
@@ -0,0 +1,34 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
const canvas = await prisma.canvas.findUnique({ where: { id } })
if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' })
// 1. Проверка прав
let canDelete = canvas.ownerId === user.id || user.role === 'admin'
if (!canDelete && canvas.groupId) {
const membership = await prisma.groupMember.findUnique({
where: {
userId_groupId: { userId: user.id, groupId: canvas.groupId }
}
})
// Только админ группы может удалять чужие карты внутри группы
if (membership && membership.role === 'admin') {
canDelete = true
}
}
if (!canDelete) {
throw createError({ statusCode: 403, message: 'Удалять карты может только владелец или администратор группы.' })
}
await prisma.canvas.delete({ where: { id } })
return { success: true, message: 'Canvas deleted' }
})
+31
View File
@@ -0,0 +1,31 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
const canvas = await prisma.canvas.findUnique({ where: { id } })
if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' })
let user = { id: null, role: 'guest' } as any
try {
user = requireAuth(event)
} catch (e) {}
// Определение роли
let userRole = 'viewer'
if (user.id === canvas.ownerId) userRole = 'owner'
else if (user.role === 'admin') userRole = 'admin'
else if (canvas.groupId) {
const membership = user.id ? await prisma.groupMember.findUnique({
where: { userId_groupId: { userId: user.id as string, groupId: canvas.groupId } }
}) : null
if (membership) userRole = membership.role
else if (!canvas.isPublic) throw createError({ statusCode: 403, message: 'Access denied' })
} else if (!canvas.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
return { canvas, role: userRole }
})
+46
View File
@@ -0,0 +1,46 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
const body = await readBody(event)
const existing = await prisma.canvas.findUnique({ where: { id } })
if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
// 1. Проверка прав
let canEdit = existing.ownerId === user.id || user.role === 'admin' || existing.isPublic
if (!canEdit && existing.groupId) {
const membership = await prisma.groupMember.findUnique({
where: {
userId_groupId: { userId: user.id, groupId: existing.groupId }
}
})
// Редакторы и админы группы могут править
if (membership && (membership.role === 'admin' || membership.role === 'editor')) {
canEdit = true
}
}
if (!canEdit) {
throw createError({ statusCode: 403, message: 'У вас недостаточно прав для редактирования этой карты' })
}
const { name, nodes, edges, isPublic, groupId } = body
const canvas = await prisma.canvas.update({
where: { id },
data: {
...(name !== undefined && { name }),
...(nodes !== undefined && { nodes }),
...(edges !== undefined && { edges }),
...(isPublic !== undefined && { isPublic }),
...(groupId !== undefined && { groupId: groupId || null })
}
})
return { canvas }
})
@@ -0,0 +1,46 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db'
import type { CanvasData } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
const original = canvases.get(id)
if (!original) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Check permissions
if (original.ownerId !== user.id && !original.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
const newId = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const duplicate: CanvasData = {
...original,
id: newId,
name: `${original.name} (Copy)`,
ownerId: user.id,
groupId: undefined, // Duplicates are personal
isPublic: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: original.nodes.map(node => ({
...node,
id: `${node.id}-copy-${Date.now()}`
})),
edges: original.edges.map(edge => ({
...edge,
id: `${edge.id}-copy-${Date.now()}`
}))
}
canvases.set(newId, duplicate)
return { canvas: duplicate }
})
+46
View File
@@ -0,0 +1,46 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const userGroups = await prisma.groupMember.findMany({
where: { userId: user.id },
select: { groupId: true }
})
const groupIds = userGroups.map(m => m.groupId)
const canvasList = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ isPublic: true },
{ groupId: { in: groupIds } }
]
},
select: {
id: true,
name: true,
ownerId: true,
groupId: true,
isPublic: true,
createdAt: true,
updatedAt: true,
nodes: true
},
orderBy: { updatedAt: 'desc' }
})
const totalCount = await prisma.canvas.count()
const canvases = canvasList.map(c => ({
...c,
nodeCount: Array.isArray(c.nodes) ? (c.nodes as any[]).length : 0,
nodes: undefined
}))
return {
canvases,
totalCount
}
})
+32
View File
@@ -0,0 +1,32 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { name = 'New Canvas', nodes = [], edges = [], isPublic = false, groupId } = body
if (groupId) {
const membership = await prisma.groupMember.findUnique({
where: { userId_groupId: { userId: user.id, groupId } }
})
if (!membership) {
throw createError({ statusCode: 403, message: 'Not a member of this group' })
}
}
const id = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const canvas = await prisma.canvas.create({
data: {
id,
name,
nodes,
edges,
isPublic,
ownerId: user.id,
groupId: groupId || null
}
})
return { canvas }
})
@@ -0,0 +1,33 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
// Проверяем права: только владелец может менять настройки доступа
const canvas = await prisma.canvas.findUnique({
where: { id }
})
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
if (canvas.ownerId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Only owner can change settings' })
}
// Обновляем поля
const updatedCanvas = await prisma.canvas.update({
where: { id },
data: {
name: body.name !== undefined ? body.name : undefined,
isPublic: body.isPublic !== undefined ? body.isPublic : undefined,
groupId: body.groupId !== undefined ? body.groupId : undefined,
}
})
return { success: true, canvas: updatedCanvas }
})
+36
View File
@@ -0,0 +1,36 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
import { canvases } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, autoSync = true } = body
if (!canvasId) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
// Check if canvas exists and user has access
const canvas = canvases.get(canvasId)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Check permissions
if (canvas.ownerId !== user.id && !canvas.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
try {
const ok = await saveCanvasToGitea(canvasId, canvas, `Auto-sync: ${canvas.name}`)
return {
success: ok,
message: ok ? 'Canvas synchronized with Gitea' : 'Gitea sync failed',
autoSyncEnabled: autoSync
}
} catch (error) {
console.error('Failed to sync canvas to Gitea:', error)
throw createError({ statusCode: 500, message: 'Failed to sync canvas to Gitea' })
}
})
+68
View File
@@ -0,0 +1,68 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea, loadCanvasFromGitea } from '~/server/utils/giteaApi'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, action, commitMessage } = body
if (!canvasId || !action) {
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
}
try {
switch (action) {
case 'init':
case 'sync': {
const canvas = await prisma.canvas.findUnique({ where: { id: canvasId } })
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
if (canvas.ownerId !== user.id && !canvas.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
const opts = { username: user.username, groupId: canvas.groupId, isPublic: canvas.isPublic }
const ok = await saveCanvasToGitea(canvasId, canvas, opts, commitMessage)
return {
success: ok,
message: ok
? `Canvas ${canvasId} saved to Gitea`
: `Failed to save canvas ${canvasId} to Gitea`
}
}
case 'load': {
const existing = await prisma.canvas.findUnique({ where: { id: canvasId } })
const opts = existing ? { username: user.username, groupId: existing.groupId, isPublic: existing.isPublic } : { username: user.username }
const gitCanvas = await loadCanvasFromGitea(canvasId, opts)
if (!gitCanvas) {
return { success: false, message: 'Canvas not found in Gitea', needsInit: true }
}
if (existing) {
await prisma.canvas.update({
where: { id: canvasId },
data: {
nodes: gitCanvas.nodes || [],
edges: gitCanvas.edges || []
}
})
}
return { success: true, canvas: gitCanvas, message: `Canvas ${canvasId} loaded from Gitea` }
}
default:
throw createError({ statusCode: 400, message: 'Invalid action' })
}
} catch (error: any) {
const details = error.data || error.message || error
console.error('Gitea sync error details:', details)
throw createError({
statusCode: 500,
message: typeof details === 'string' ? details : JSON.stringify(details)
})
}
})
@@ -0,0 +1,47 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, type } = body
if (!canvasId || !type) {
throw createError({ statusCode: 400, message: 'Canvas ID and repository type are required' })
}
// Check if canvas exists and user has access
const canvas = canvases.get(canvasId)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
if (canvas.ownerId !== user.id && !canvas.isPublic) {
// Check group access
if (canvas.groupId) {
const membership = Array.from(userGroups.values()).find(
m => m.userId === user.id && m.groupId === canvas.groupId
)
if (!membership) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
} else {
throw createError({ statusCode: 403, message: 'Access denied' })
}
}
try {
// Initial save to Gitea creates the canvas file in the repo
const ok = await saveCanvasToGitea(canvasId, canvas, `Init canvas ${canvasId}`)
return {
success: ok,
message: ok
? `Canvas ${canvasId} initialized in Gitea`
: `Failed to initialize canvas ${canvasId} in Gitea`
}
} catch (error) {
console.error('Failed to initialize canvas in Gitea:', error)
throw createError({ statusCode: 500, message: 'Failed to initialize canvas in Gitea' })
}
})
@@ -0,0 +1,54 @@
import { requireAuth } from '~/server/utils/auth'
import { resolveRepo } from '~/server/utils/giteaApi'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const canvasId = getRouterParam(event, 'canvasId')
if (!canvasId) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
const canvas = await prisma.canvas.findUnique({ where: { id: canvasId } })
const opts = canvas
? { username: user.username, groupId: canvas.groupId, isPublic: canvas.isPublic }
: { username: user.username }
const { owner, repo } = resolveRepo(opts)
const config = useRuntimeConfig()
const { giteaUrl: url, giteaToken: token } = config
try {
const res = await $fetch<any>(`${url}/api/v1/repos/${owner}/${repo}`, {
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/json'
}
})
return {
success: true,
repository: {
id: res.id,
name: res.name,
owner: res.owner.login,
url: res.html_url,
cloneUrl: res.clone_url,
isPrivate: res.private,
type: 'personal',
ownerId: user.id,
createdAt: res.created_at,
updatedAt: res.updated_at
}
}
} catch (error: any) {
if (error.response?.status === 404) {
return { success: false, repository: null, message: 'Repository not found' }
}
console.error('Failed to fetch repository:', error)
throw createError({ statusCode: 500, message: 'Failed to fetch repository details from Gitea' })
}
})
+23
View File
@@ -0,0 +1,23 @@
import { requireAuth } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, action, commitMessage } = body
if (!canvasId || !action) {
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
}
try {
// Git sync logic will be implemented here
// For now, return success response
return {
success: true,
message: `Git ${action} completed for canvas ${canvasId}`,
commitMessage: commitMessage || `Auto-sync canvas ${canvasId}`
}
} catch (error) {
throw createError({ statusCode: 500, message: 'Git sync failed' })
}
})
+45
View File
@@ -0,0 +1,45 @@
// Test endpoint: GET /api/git/test
// Verifies Gitea connection and repo access
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const { giteaUrl, giteaToken, giteaOrg, giteaMindmapRepo } = config
if (!giteaUrl || !giteaToken) {
return { success: false, message: 'GITEA_URL or GITEA_TOKEN not set in .env' }
}
try {
// Test 1: check repo exists
const repo = await $fetch<any>(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}`, {
headers: { 'Authorization': `token ${giteaToken}` }
})
// Test 2: check/create canvases folder by listing contents
let folderExists = false
try {
await $fetch(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}/contents/canvases`, {
headers: { 'Authorization': `token ${giteaToken}` }
})
folderExists = true
} catch {
folderExists = false
}
return {
success: true,
repo: repo.full_name,
private: repo.private,
folderExists,
message: folderExists
? '✅ Gitea connected, canvases/ folder exists'
: '✅ Gitea connected, canvases/ folder will be created on first save'
}
} catch (e: any) {
return {
success: false,
message: `❌ Gitea connection failed: ${e?.message || e}`,
hint: 'Check GITEA_URL, GITEA_TOKEN, GITEA_ORG, GITEA_MINDMAP_REPO in .env'
}
}
})
+17
View File
@@ -0,0 +1,17 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = await prisma.group.findUnique({ where: { id: groupId } })
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (group.ownerId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Only owner can delete group' })
}
await prisma.group.delete({ where: { id: groupId } })
return { success: true }
})
@@ -0,0 +1,53 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const adminUser = requireAuth(event)
const groupId = getRouterParam(event, 'id')
const body = await readBody(event)
const { email, userId, role = 'member' } = body
if (!groupId || (!email && !userId)) {
throw createError({ statusCode: 400, message: 'Group ID and either Email or User ID are required' })
}
// 1. Проверяем права
const group = await prisma.group.findUnique({ where: { id: groupId } })
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
throw createError({ statusCode: 403, message: 'Only group owners or admins can add members' })
}
// 2. Находим пользователя
let userToAdd = null
if (userId) {
userToAdd = await prisma.user.findUnique({ where: { id: userId } })
} else if (email) {
userToAdd = await prisma.user.findUnique({ where: { email } })
}
if (!userToAdd) {
throw createError({ statusCode: 404, message: 'User not found' })
}
// 3. Добавляем в группу
try {
const membership = await prisma.groupMember.upsert({
where: {
userId_groupId: {
userId: userToAdd.id,
groupId: groupId
}
},
update: { role },
create: {
userId: userToAdd.id,
groupId: groupId,
role
}
})
return { success: true, membership }
} catch (e) {
throw createError({ statusCode: 500, message: 'Failed to add member to group' })
}
})
@@ -0,0 +1,35 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
const group = await prisma.group.findUnique({
where: { id },
include: {
members: {
include: {
user: {
select: { id: true, username: true, email: true }
}
}
},
canvases: {
select: { id: true, name: true, updatedAt: true }
}
}
})
if (!group) {
throw createError({ statusCode: 404, message: 'Group not found' })
}
// Проверяем, имеет ли пользователь право видеть детали группы
const isMember = group.members.some(m => m.userId === user.id)
if (!group.isPublic && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Access denied' })
}
return { group }
})
@@ -0,0 +1,22 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = await prisma.group.findUnique({ where: { id: groupId } })
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (!group.isPublic) throw createError({ statusCode: 403, message: 'Group is private' })
const existing = await prisma.groupMember.findUnique({
where: { userId_groupId: { userId: user.id, groupId } }
})
if (existing) return { message: 'Already a member' }
await prisma.groupMember.create({
data: { userId: user.id, groupId, role: 'member' }
})
return { success: true, message: `Joined group "${group.name}"` }
})
@@ -0,0 +1,20 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = await prisma.group.findUnique({ where: { id: groupId } })
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (group.ownerId === user.id) throw createError({ statusCode: 400, message: 'Owner cannot leave group. Delete it instead.' })
const membership = await prisma.groupMember.findUnique({
where: { userId_groupId: { userId: user.id, groupId } }
})
if (!membership) throw createError({ statusCode: 400, message: 'Not a member' })
await prisma.groupMember.delete({ where: { id: membership.id } })
return { success: true, message: `Left group "${group.name}"` }
})
@@ -0,0 +1,41 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const adminUser = requireAuth(event)
const groupId = getRouterParam(event, 'id')
const body = await readBody(event)
const { userId, role } = body
if (!groupId || !userId || !role) {
throw createError({ statusCode: 400, message: 'Missing required fields' })
}
// 1. Проверяем права: только владелец группы или глобальный админ может менять роли
const group = await prisma.group.findUnique({
where: { id: groupId }
})
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
throw createError({ statusCode: 403, message: 'Permission denied' })
}
// 2. Нельзя менять роль самому владельцу (он всегда админ)
if (userId === group.ownerId) {
throw createError({ statusCode: 400, message: 'Cannot change owner role' })
}
// 3. Обновляем роль
try {
const updated = await prisma.groupMember.update({
where: {
userId_groupId: { userId, groupId }
},
data: { role }
})
return { success: true, updated }
} catch (e) {
throw createError({ statusCode: 500, message: 'Failed to update member role' })
}
})
+30
View File
@@ -0,0 +1,30 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const allGroups = await prisma.group.findMany({
where: user.role === 'admin' ? {} : { isPublic: true },
include: {
members: { include: { user: { select: { id: true, username: true } } } },
_count: { select: { members: true, canvases: true } }
},
orderBy: { createdAt: 'desc' }
})
return {
groups: allGroups.map(g => ({
id: g.id,
name: g.name,
description: g.description,
isPublic: g.isPublic,
ownerId: g.ownerId,
createdAt: g.createdAt,
updatedAt: g.updatedAt,
memberCount: g._count.members,
canvasCount: g._count.canvases,
isMember: g.members.some(m => m.userId === user.id)
}))
}
})
+29
View File
@@ -0,0 +1,29 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { name, description = '', isPublic = true } = body
if (!name?.trim()) {
throw createError({ statusCode: 400, message: 'Group name is required' })
}
const group = await prisma.group.create({
data: {
name: name.trim(),
description,
isPublic,
ownerId: user.id,
members: {
create: {
userId: user.id,
role: 'admin'
}
}
}
})
return { group }
})
+26
View File
@@ -0,0 +1,26 @@
import { requireAuth } from '~/server/utils/auth'
import { nodes, addAchievement } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const existing = nodes.get(id || '') || { id, state: 'pending', assigned_to: user.id }
// Update state
const updated = { ...existing, ...body, updatedAt: new Date().toISOString() }
nodes.set(id || '', updated)
// Gamification: achievement on completion
if (body.state === 'completed' && existing.state !== 'completed') {
addAchievement(user.id, {
id: `completed-${id}`,
title: 'Задача выполнена',
description: `Нода ${id} завершена`,
icon: '✅'
})
}
return { node: updated }
})
@@ -0,0 +1,105 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
import { generateAIResponse } from '~/server/utils/ai'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const nodeId = getRouterParam(event, 'id')
const body = await readBody(event)
const { canvasId, context } = body
console.log(`[Execute] nodeId=${nodeId} canvasId=${canvasId} userId=${user.id}`)
if (!nodeId || !canvasId) {
throw createError({ statusCode: 400, message: 'Node ID and Canvas ID are required' })
}
// 1. Найти холст и убедиться в правах доступа
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId }
})
console.log(`[Execute] Canvas found: ${!!canvas}, nodes count: ${(canvas?.nodes as any[])?.length}`)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Проверка прав (владелец или админ)
if (canvas.ownerId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Access denied' })
}
// 2. Найти конкретный узел в JSON холста
const nodes = (canvas.nodes as any[]) || []
const nodeIdx = nodes.findIndex(n => n.id === nodeId)
console.log(`[Execute] Node found at index: ${nodeIdx}`)
if (nodeIdx === -1) {
throw createError({ statusCode: 404, message: `Node ${nodeId} not found in canvas ${canvasId}` })
}
const node = nodes[nodeIdx]
console.log(`[Execute] Node type: ${node.type}`)
try {
let result = ''
if (node.type === 'ai') {
// Формируем промпт с учетом контекста от соседей
let fullPrompt = node.prompt || node.text || 'Analyze the provided context.'
if (context && context.length > 0) {
const contextText = context.map((c: any) => `[Node ${c.id}]: ${c.text}`).join('\n\n')
fullPrompt = `CONTEXT INFORMATION:\n${contextText}\n\nUSER REQUEST/INSTRUCTION:\n${fullPrompt}`
}
// Модель можно задать прямо в узле: node.model = 'qwen_coder' | 'llama' | 'gemma' | 'laguna' | 'nemotron'
const model = node.model || undefined
console.log(`[AI Execute] model=${model || 'default'} nodeId=${nodeId}`)
result = await generateAIResponse(fullPrompt, 'You are a helpful assistant.', model)
// Сохраняем результат в текст узла
nodes[nodeIdx].text = result
} else if (node.type === 'webhook') {
const url = node.url
const method = node.method || 'GET'
if (!url) throw new Error('Webhook URL is not configured')
console.log(`[Webhook Execute] Calling ${method} ${url}...`)
const response = await $fetch<any>(url, {
method,
...(method === 'POST' ? { body: { context, timestamp: new Date() } } : {})
})
result = typeof response === 'string' ? response : JSON.stringify(response, null, 2)
nodes[nodeIdx].text = `### Response from ${url}\n\n\`\`\`json\n${result}\n\`\`\``
} else {
throw createError({ statusCode: 400, message: `Execution not supported for type: ${node.type}` })
}
// 3. Сохраняем обновленный холст
await prisma.canvas.update({
where: { id: canvasId },
data: { nodes }
})
return {
success: true,
result,
message: 'Node executed and canvas updated'
}
} catch (error: any) {
console.error('Node execution error:', error)
throw createError({
statusCode: 500,
message: error.message || 'Execution failed'
})
}
})
+14
View File
@@ -0,0 +1,14 @@
import { requireAuth } from '~/server/utils/auth'
import { nodes } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// RBAC: filter nodes by assigned_to or role
const allNodes = Array.from(nodes.values())
const filtered = user.role === 'admin'
? allNodes
: allNodes.filter((n: any) => n.assigned_to === user.id || n.assigned_to === 'all')
return { nodes: filtered }
})
+21
View File
@@ -0,0 +1,21 @@
import { requireRole } from '~/server/utils/auth'
import { pushSubscriptions } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
requireRole(event, 'admin')
const body = await readBody(event)
const { title, message, userId } = body
const targets = userId
? [pushSubscriptions.get(userId)].filter(Boolean)
: Array.from(pushSubscriptions.values())
// In production, use web-push library with VAPID keys
const results = targets.map((sub: any) => ({
userId: sub.userId,
sent: true,
notification: { title, message, timestamp: new Date().toISOString() }
}))
return { sent: results.length, results }
})
@@ -0,0 +1,16 @@
import { requireAuth } from '~/server/utils/auth'
import { pushSubscriptions } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { subscription } = body
if (!subscription?.endpoint) {
throw createError({ statusCode: 400, message: 'Invalid subscription' })
}
pushSubscriptions.set(user.id, { userId: user.id, subscription, createdAt: new Date().toISOString() })
return { success: true }
})
+56
View File
@@ -0,0 +1,56 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const userGroups = await prisma.groupMember.findMany({
where: { userId: user.id },
select: { groupId: true }
})
const groupIds = userGroups.map(m => m.groupId)
// Get all canvases user has access to
const canvasList = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ isPublic: true },
{ groupId: { in: groupIds } }
]
},
select: {
id: true,
name: true,
nodes: true
}
})
// Extract tasks from all canvases
const allTasks = []
for (const canvas of canvasList) {
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes :
(typeof canvas.nodes === 'string' ? JSON.parse(canvas.nodes) : [])
if (!Array.isArray(nodes)) continue
const canvasTasks = nodes.filter(n => {
const type = n.type || n.data?.type
if (type !== 'task') return false
// Temporary: Show ALL tasks regardless of assignee so you can see them!
return true
})
for (const t of canvasTasks) {
allTasks.push({
...t,
canvasId: canvas.id,
canvasName: canvas.name
})
}
}
return { tasks: allTasks }
})
+69
View File
@@ -0,0 +1,69 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, title, priority, state } = body
if (!canvasId || !title) {
throw createError({ statusCode: 400, message: 'Canvas ID and Title are required' })
}
// 1. Получаем канвас
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId }
})
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// 2. Проверяем права
const isOwner = canvas.ownerId === user.id
let isMember = false
if (canvas.groupId) {
const membership = await prisma.groupMember.findFirst({
where: { userId: user.id, groupId: canvas.groupId }
})
isMember = !!membership
}
if (!isOwner && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'No permission to add tasks to this canvas' })
}
// 3. Создаем новый узел задачи
const nodes = (canvas.nodes as any[]) || []
// Генерируем позицию (просто в центре или скраю)
const lastNode = nodes[nodes.length - 1]
const x = lastNode?.position?.x || 100
const y = (lastNode?.position?.y || 100) + 100
const newTaskNode = {
id: `task-${Date.now()}`,
type: 'task',
position: { x, y },
data: {
text: `# ${title}`,
priority: priority || 'medium',
state: state || 'todo',
createdAt: new Date().toISOString()
},
// Дублируем поля для удобства фильтрации на бэкенде
priority: priority || 'medium',
state: state || 'todo',
label: title
}
nodes.push(newTaskNode)
// 4. Сохраняем в БД
await prisma.canvas.update({
where: { id: canvasId },
data: { nodes }
})
return { success: true, task: newTaskNode }
})
+55
View File
@@ -0,0 +1,55 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// 1. Получаем группы пользователя
const userGroups = await prisma.groupMember.findMany({
where: { userId: user.id },
select: { groupId: true }
})
const groupIds = userGroups.map(g => g.groupId)
// 2. Получаем все доступные канвасы
const canvases = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { in: groupIds } },
{ isPublic: true }
]
},
select: {
id: true,
name: true,
nodes: true
}
})
// 3. Собираем задачи из всех канвасов
const allTasks: any[] = []
for (const canvas of canvases) {
const nodes = (canvas.nodes as any[]) || []
const taskNodes = nodes.filter(node => node.type === 'task')
taskNodes.forEach(task => {
allTasks.push({
...task,
canvasId: canvas.id,
canvasName: canvas.name
})
})
}
// Сортируем: сначала высокий приоритет, потом дата
allTasks.sort((a, b) => {
const pPriority = { high: 3, medium: 2, low: 1 }
const prioA = pPriority[a.priority || a.data?.priority] || 0
const prioB = pPriority[b.priority || b.data?.priority] || 0
return prioB - prioA
})
return { tasks: allTasks }
})
@@ -0,0 +1,57 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { taskId, canvasId, newState } = body
if (!taskId || !canvasId || !newState) {
throw createError({ statusCode: 400, message: 'Missing parameters' })
}
// 1. Получаем канвас
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId }
})
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// 2. Проверяем права на редактирование
const isOwner = canvas.ownerId === user.id
let isMember = false
if (canvas.groupId) {
const membership = await prisma.groupMember.findFirst({
where: { userId: user.id, groupId: canvas.groupId }
})
isMember = !!membership
}
if (!isOwner && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'No permission to update this task' })
}
// 3. Обновляем узел внутри массива nodes
const nodes = (canvas.nodes as any[]) || []
const nodeIdx = nodes.findIndex(n => n.id === taskId)
if (nodeIdx === -1) {
throw createError({ statusCode: 404, message: 'Task node not found in canvas' })
}
// Обновляем состояние
nodes[nodeIdx].state = newState
if (nodes[nodeIdx].data) {
nodes[nodeIdx].data.state = newState
}
// 4. Сохраняем обратно в БД
await prisma.canvas.update({
where: { id: canvasId },
data: { nodes }
})
return { success: true }
})
+21
View File
@@ -0,0 +1,21 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// Находим группы, где пользователь либо владелец, либо участник
const groups = await prisma.group.findMany({
where: {
OR: [
{ ownerId: user.id },
{ members: { some: { userId: user.id } } }
]
},
orderBy: {
name: 'asc'
}
})
return { groups }
})
+20
View File
@@ -0,0 +1,20 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { displayName, bio, avatar } = body
// Обновляем данные в таблице Profile
const updatedProfile = await prisma.profile.update({
where: { userId: user.id },
data: {
...(displayName !== undefined && { displayName }),
...(bio !== undefined && { bio }),
...(avatar !== undefined && { avatar })
}
})
return { success: true, profile: updatedProfile }
})
+53
View File
@@ -0,0 +1,53 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// 1. Считаем канвасы (личные и где участник)
const canvasCount = await prisma.canvas.count({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { not: null } } // Тут можно усложнить, но для начала так
]
}
})
// 2. Считаем задачи во всех доступных канвасах
const canvases = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { not: null } }
]
},
select: { nodes: true }
})
let totalTasks = 0
let completedTasks = 0
for (const canvas of canvases) {
const nodes = canvas.nodes
if (Array.isArray(nodes)) {
const tasks = nodes.filter(n => n.type === 'task')
totalTasks += tasks.length
completedTasks += tasks.filter(t => (t.data?.state === 'completed' || t.state === 'completed')).length
}
}
// 3. Ачивки (пока возвращаем 0, так как модели нет в схеме)
const achievementCount = 0
return {
stats: {
canvases: canvasCount,
tasks: {
total: totalTasks,
completed: completedTasks
},
achievements: achievementCount
}
}
})
@@ -0,0 +1,30 @@
import { activities } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
// Get user's recent activities
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
// Get user's activities from database or memory
const userActivities = await prisma.activity.findMany({
where: { userId: user.id },
orderBy: { timestamp: 'desc' },
take: 20
})
// Limit to 50 most recent
return { activities: userActivities }
})
@@ -0,0 +1,42 @@
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
// Get user's group memberships
const userGroupIds = Array.from(userGroups.values())
.filter(membership => membership.userId === userId)
.map(membership => membership.groupId)
// Get canvases owned by user or shared with user's groups
const userCanvases = Array.from(canvases.values()).filter(canvas => {
// User's own canvases
if (canvas.ownerId === userId) return true
// Group canvases
if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true
return false
})
const canvasList = userCanvases.map(canvas => ({
id: canvas.id,
name: canvas.name,
ownerId: canvas.ownerId,
createdAt: canvas.createdAt,
updatedAt: canvas.updatedAt,
isPublic: canvas.isPublic,
groupId: canvas.groupId,
nodeCount: canvas.nodes.length
}))
return { canvases: canvasList }
})
@@ -0,0 +1,39 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const targetUserIdOrUsername = getRouterParam(event, 'id')
if (!targetUserIdOrUsername) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
// Находим целевого пользователя (по ID или username)
const targetUser = await prisma.user.findFirst({
where: {
OR: [
{ id: targetUserIdOrUsername },
{ username: targetUserIdOrUsername }
]
}
})
if (!targetUser) {
throw createError({ statusCode: 404, message: 'Target user not found' })
}
try {
const follow = await prisma.follows.findUnique({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: targetUser.id
}
}
})
return { isFollowing: !!follow }
} catch (e) {
return { isFollowing: false }
}
})
@@ -0,0 +1,40 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const targetIdOrUsername = getRouterParam(event, 'id')
const targetUser = await prisma.user.findFirst({
where: {
OR: [
{ id: targetIdOrUsername },
{ username: targetIdOrUsername }
]
}
})
if (!targetUser) {
throw createError({ statusCode: 404, message: 'User not found' })
}
if (targetUser.id === currentUser.id) {
throw createError({ statusCode: 400, message: 'You cannot follow yourself' })
}
await prisma.follows.upsert({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: targetUser.id
}
},
update: {},
create: {
followerId: currentUser.id,
followingId: targetUser.id
}
})
return { success: true }
})
@@ -0,0 +1,66 @@
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
let profile = await prisma.profile.findFirst({
where: {
OR: [
{ userId: userId },
{ user: { username: userId } }
]
},
include: {
user: {
include: {
_count: {
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
// If profile doesn't exist but user does, create it
if (!profile) {
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
profile = await prisma.profile.create({
data: {
userId: user.id,
displayName: user.username,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
},
include: {
user: {
include: {
_count: {
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
}
return {
profile: {
...profile,
canvasCount: profile.user._count.canvases,
followerCount: profile.user._count.followers,
followingCount: profile.user._count.following
}
}
})
+32
View File
@@ -0,0 +1,32 @@
import { defineEventHandler } from 'h3'
import { prisma } from '~/server/utils/prisma'
import { requireAuth } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
if (!user) return []
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
profile: {
select: {
displayName: true,
avatar: true
}
}
}
})
return users.map(u => ({
id: u.id,
username: u.username,
email: u.email,
role: u.role,
displayName: u.profile?.displayName || u.username,
avatar: u.profile?.avatar
}))
})
+28
View File
@@ -0,0 +1,28 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
requireAuth(event)
const query = getQuery(event).q as string
if (!query || query.length < 2) {
return { users: [] }
}
const users = await prisma.user.findMany({
where: {
OR: [
{ username: { contains: query, mode: 'insensitive' } },
{ email: { contains: query, mode: 'insensitive' } }
]
},
select: {
id: true,
username: true,
email: true
},
take: 10
})
return { users }
})
+18
View File
@@ -0,0 +1,18 @@
import { getListsUseCase } from '../../../modules/notes-lists';
import { getAuthUser } from '../../../utils/auth';
export default defineEventHandler(async (event) => {
const user = getAuthUser(event);
const ownerId = user?.id || (getQuery(event).userId as string) || 'default-user';
const category = getQuery(event).category as string | undefined;
const result = await getListsUseCase.execute({ ownerId, category });
if (result.isFailure) {
throw createError({ statusCode: 400, message: result.getError().message });
}
return {
success: true,
lists: result.getValue().map(l => l.toJSON())
};
});
@@ -0,0 +1,35 @@
import { addListItemUseCase } from '../../../modules/notes-lists';
import { getAuthUser } from '../../../utils/auth';
export default defineEventHandler(async (event) => {
const user = getAuthUser(event);
const body = await readBody(event);
const ownerId = user?.id || body.ownerId || 'default-user';
const { title, listId, listTitle, category, description, priority, metadata } = body;
if (!title) {
throw createError({ statusCode: 400, message: 'Item title is required' });
}
const result = await addListItemUseCase.execute({
title,
listId,
listTitle,
category,
description,
priority,
metadata,
ownerId
});
if (result.isFailure) {
const error = result.getError();
throw createError({ statusCode: error.statusCode || 400, message: error.message });
}
return {
success: true,
item: result.getValue().toJSON()
};
});
@@ -0,0 +1,29 @@
import { toggleListItemUseCase } from '../../../../modules/notes-lists';
import { getAuthUser } from '../../../../utils/auth';
export default defineEventHandler(async (event) => {
const itemId = getRouterParam(event, 'id');
if (!itemId) {
throw createError({ statusCode: 400, message: 'Item ID is required' });
}
const user = getAuthUser(event);
const body = await readBody(event).catch(() => ({}));
const ownerId = user?.id || body.ownerId || 'default-user';
const result = await toggleListItemUseCase.execute({
itemId,
status: body.status,
ownerId
});
if (result.isFailure) {
const error = result.getError();
throw createError({ statusCode: error.statusCode || 400, message: error.message });
}
return {
success: true,
item: result.getValue().toJSON()
};
});
@@ -0,0 +1,6 @@
import { Result } from '../domain/Result';
import { DomainError } from '../domain/DomainError';
export interface IUseCase<TRequest, TResponse> {
execute(request: TRequest): Promise<Result<TResponse, DomainError>>;
}
@@ -0,0 +1,29 @@
export abstract class DomainError extends Error {
public abstract readonly code: string;
public abstract readonly statusCode: number;
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class NotFoundError extends DomainError {
public readonly code = 'NOT_FOUND';
public readonly statusCode = 404;
}
export class ValidationError extends DomainError {
public readonly code = 'VALIDATION_ERROR';
public readonly statusCode = 400;
}
export class UnauthorizedError extends DomainError {
public readonly code = 'UNAUTHORIZED';
public readonly statusCode = 401;
}
export class ForbiddenError extends DomainError {
public readonly code = 'FORBIDDEN';
public readonly statusCode = 403;
}
+37
View File
@@ -0,0 +1,37 @@
export class Result<T, E = Error> {
private constructor(
private readonly _isSuccess: boolean,
private readonly _value?: T,
private readonly _error?: E
) {}
public static ok<U>(value: U): Result<U, never> {
return new Result<U, never>(true, value);
}
public static fail<F>(error: F): Result<never, F> {
return new Result<never, F>(false, undefined, error);
}
public get isSuccess(): boolean {
return this._isSuccess;
}
public get isFailure(): boolean {
return !this._isSuccess;
}
public getValue(): T {
if (!this._isSuccess) {
throw new Error(`Cannot get value from failed Result: ${this._error}`);
}
return this._value as T;
}
public getError(): E {
if (this._isSuccess) {
throw new Error("Cannot get error from successful Result");
}
return this._error as E;
}
}
@@ -0,0 +1,67 @@
import { IUseCase } from '../../core/application/IUseCase';
import { Result } from '../../core/domain/Result';
import { DomainError, ValidationError } from '../../core/domain/DomainError';
import { IListRepository } from '../domain/IListRepository';
import { NoteList } from '../domain/NoteList';
import { ListItem } from '../domain/ListItem';
import { AddListItemDto } from './dtos/ListDtos';
export class AddListItemUseCase implements IUseCase<AddListItemDto, ListItem> {
constructor(private readonly listRepository: IListRepository) {}
public async execute(dto: AddListItemDto): Promise<Result<ListItem, DomainError>> {
if (!dto.title || dto.title.trim().length === 0) {
return Result.fail(new ValidationError('Item title is required'));
}
if (!dto.ownerId) {
return Result.fail(new ValidationError('Owner ID is required'));
}
let targetList: NoteList | null = null;
if (dto.listId) {
targetList = await this.listRepository.findListById(dto.listId);
} else if (dto.listTitle) {
targetList = await this.listRepository.findListByTitleAndOwner(dto.listTitle.trim(), dto.ownerId);
}
// Если список не найден по названию (например, первая запись фильма), создаем список автоматически
if (!targetList) {
const listTitle = dto.listTitle?.trim() || 'Общий список';
const category = dto.category || (listTitle.toLowerCase().includes('фильм') ? 'movies' : 'general');
targetList = new NoteList({
id: `list-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
title: listTitle,
category: category,
description: `Автоматически создан для ${listTitle}`,
ownerId: dto.ownerId,
createdAt: new Date(),
updatedAt: new Date(),
items: []
});
await this.listRepository.saveList(targetList);
}
const newItem = new ListItem({
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
listId: targetList.id,
title: dto.title.trim(),
description: dto.description || '',
isCompleted: false,
status: targetList.category === 'movies' ? 'want_to_watch' : 'todo',
priority: dto.priority || 'normal',
metadata: dto.metadata || {},
dueDate: null,
createdAt: new Date(),
updatedAt: new Date()
});
await this.listRepository.saveItem(newItem);
targetList.addItem(newItem);
return Result.ok(newItem);
}
}
@@ -0,0 +1,23 @@
import { IUseCase } from '../../core/application/IUseCase';
import { Result } from '../../core/domain/Result';
import { DomainError, ValidationError } from '../../core/domain/DomainError';
import { IListRepository } from '../domain/IListRepository';
import { NoteList } from '../domain/NoteList';
export interface GetListsRequest {
ownerId: string;
category?: string;
}
export class GetListsUseCase implements IUseCase<GetListsRequest, NoteList[]> {
constructor(private readonly listRepository: IListRepository) {}
public async execute(req: GetListsRequest): Promise<Result<NoteList[], DomainError>> {
if (!req.ownerId) {
return Result.fail(new ValidationError('Owner ID is required'));
}
const lists = await this.listRepository.findListsByOwner(req.ownerId, req.category);
return Result.ok(lists);
}
}
@@ -0,0 +1,30 @@
import { IUseCase } from '../../core/application/IUseCase';
import { Result } from '../../core/domain/Result';
import { DomainError, NotFoundError, ValidationError } from '../../core/domain/DomainError';
import { IListRepository } from '../domain/IListRepository';
import { ListItem } from '../domain/ListItem';
import { ToggleListItemDto } from './dtos/ListDtos';
export class ToggleListItemUseCase implements IUseCase<ToggleListItemDto, ListItem> {
constructor(private readonly listRepository: IListRepository) {}
public async execute(dto: ToggleListItemDto): Promise<Result<ListItem, DomainError>> {
if (!dto.itemId) {
return Result.fail(new ValidationError('Item ID is required'));
}
const item = await this.listRepository.findItemById(dto.itemId);
if (!item) {
return Result.fail(new NotFoundError(`Item with ID ${dto.itemId} not found`));
}
if (dto.status) {
item.updateStatus(dto.status);
} else {
item.toggleCompleted();
}
await this.listRepository.saveItem(item);
return Result.ok(item);
}
}
@@ -0,0 +1,25 @@
export interface CreateListDto {
title: string;
category?: string;
description?: string;
color?: string;
icon?: string;
ownerId: string;
}
export interface AddListItemDto {
listId?: string;
listTitle?: string; // Если передано имя (например из голосового ввода Gemini или CLI "Фильмы")
category?: string; // Категория по умолчанию, если список создается на лету
title: string;
description?: string;
priority?: string;
metadata?: Record<string, any>;
ownerId: string;
}
export interface ToggleListItemDto {
itemId: string;
status?: string;
ownerId: string;
}
@@ -0,0 +1,14 @@
import { NoteList } from './NoteList';
import { ListItem } from './ListItem';
export interface IListRepository {
findListById(id: string): Promise<NoteList | null>;
findListByTitleAndOwner(title: string, ownerId: string): Promise<NoteList | null>;
findListsByOwner(ownerId: string, category?: string): Promise<NoteList[]>;
saveList(list: NoteList): Promise<void>;
deleteList(id: string, ownerId: string): Promise<boolean>;
findItemById(id: string): Promise<ListItem | null>;
saveItem(item: ListItem): Promise<void>;
deleteItem(id: string): Promise<boolean>;
}
@@ -0,0 +1,45 @@
export interface ListItemProps {
id: string;
listId: string;
title: string;
description?: string;
isCompleted: boolean;
status: string; // 'todo' | 'in_progress' | 'completed' | 'watching' | 'watched'
priority: string; // 'low' | 'normal' | 'high'
metadata?: Record<string, any>;
dueDate?: Date | null;
createdAt: Date;
updatedAt: Date;
}
export class ListItem {
constructor(private readonly props: ListItemProps) {}
public get id(): string { return this.props.id; }
public get listId(): string { return this.props.listId; }
public get title(): string { return this.props.title; }
public get description(): string { return this.props.description || ''; }
public get isCompleted(): boolean { return this.props.isCompleted; }
public get status(): string { return this.props.status; }
public get priority(): string { return this.props.priority; }
public get metadata(): Record<string, any> | undefined { return this.props.metadata; }
public get dueDate(): Date | null | undefined { return this.props.dueDate; }
public get createdAt(): Date { return this.props.createdAt; }
public get updatedAt(): Date { return this.props.updatedAt; }
public toggleCompleted(): void {
this.props.isCompleted = !this.props.isCompleted;
this.props.status = this.props.isCompleted ? 'completed' : 'todo';
this.props.updatedAt = new Date();
}
public updateStatus(status: string): void {
this.props.status = status;
this.props.isCompleted = (status === 'completed' || status === 'watched');
this.props.updatedAt = new Date();
}
public toJSON(): ListItemProps {
return { ...this.props };
}
}
@@ -0,0 +1,44 @@
import { ListItem } from './ListItem';
export interface NoteListProps {
id: string;
title: string;
category: string; // 'movies', 'shopping', 'tasks', 'general'
description?: string;
color?: string | null;
icon?: string | null;
ownerId: string;
createdAt: Date;
updatedAt: Date;
items?: ListItem[];
}
export class NoteList {
constructor(private readonly props: NoteListProps) {}
public get id(): string { return this.props.id; }
public get title(): string { return this.props.title; }
public get category(): string { return this.props.category; }
public get description(): string { return this.props.description || ''; }
public get color(): string | null | undefined { return this.props.color; }
public get icon(): string | null | undefined { return this.props.icon; }
public get ownerId(): string { return this.props.ownerId; }
public get createdAt(): Date { return this.props.createdAt; }
public get updatedAt(): Date { return this.props.updatedAt; }
public get items(): ListItem[] { return this.props.items || []; }
public addItem(item: ListItem): void {
if (!this.props.items) {
this.props.items = [];
}
this.props.items.push(item);
this.props.updatedAt = new Date();
}
public toJSON(): NoteListProps {
return {
...this.props,
items: this.props.items?.map(i => i.toJSON())
};
}
}
@@ -0,0 +1,11 @@
import { PrismaListRepository } from './infrastructure/PrismaListRepository';
import { AddListItemUseCase } from './application/AddListItemUseCase';
import { GetListsUseCase } from './application/GetListsUseCase';
import { ToggleListItemUseCase } from './application/ToggleListItemUseCase';
const listRepository = new PrismaListRepository();
export const addListItemUseCase = new AddListItemUseCase(listRepository);
export const getListsUseCase = new GetListsUseCase(listRepository);
export const toggleListItemUseCase = new ToggleListItemUseCase(listRepository);
export { listRepository };
@@ -0,0 +1,162 @@
import { prisma } from '../../../utils/prisma';
import { IListRepository } from '../domain/IListRepository';
import { NoteList } from '../domain/NoteList';
import { ListItem } from '../domain/ListItem';
export class PrismaListRepository implements IListRepository {
public async findListById(id: string): Promise<NoteList | null> {
const record = await (prisma as any).noteList.findUnique({
where: { id },
include: { items: true }
});
if (!record) return null;
return this.mapToDomain(record);
}
public async findListByTitleAndOwner(title: string, ownerId: string): Promise<NoteList | null> {
const record = await (prisma as any).noteList.findFirst({
where: {
title: { equals: title, mode: 'insensitive' },
ownerId
},
include: { items: true }
});
if (!record) return null;
return this.mapToDomain(record);
}
public async findListsByOwner(ownerId: string, category?: string): Promise<NoteList[]> {
const where: any = { ownerId };
if (category) {
where.category = category;
}
const records = await (prisma as any).noteList.findMany({
where,
include: { items: { orderBy: { createdAt: 'desc' } } },
orderBy: { updatedAt: 'desc' }
});
return records.map((r: any) => this.mapToDomain(r));
}
public async saveList(list: NoteList): Promise<void> {
await (prisma as any).noteList.upsert({
where: { id: list.id },
create: {
id: list.id,
title: list.title,
category: list.category,
description: list.description,
color: list.color,
icon: list.icon,
ownerId: list.ownerId,
createdAt: list.createdAt,
updatedAt: list.updatedAt
},
update: {
title: list.title,
category: list.category,
description: list.description,
color: list.color,
icon: list.icon,
updatedAt: new Date()
}
});
}
public async deleteList(id: string, ownerId: string): Promise<boolean> {
const res = await (prisma as any).noteList.deleteMany({
where: { id, ownerId }
});
return res.count > 0;
}
public async findItemById(id: string): Promise<ListItem | null> {
const record = await (prisma as any).listItem.findUnique({
where: { id }
});
if (!record) return null;
return new ListItem({
id: record.id,
listId: record.listId,
title: record.title,
description: record.description,
isCompleted: record.isCompleted,
status: record.status,
priority: record.priority,
metadata: record.metadata as any,
dueDate: record.dueDate,
createdAt: record.createdAt,
updatedAt: record.updatedAt
});
}
public async saveItem(item: ListItem): Promise<void> {
await (prisma as any).listItem.upsert({
where: { id: item.id },
create: {
id: item.id,
listId: item.listId,
title: item.title,
description: item.description,
isCompleted: item.isCompleted,
status: item.status,
priority: item.priority,
metadata: item.metadata || {},
dueDate: item.dueDate,
createdAt: item.createdAt,
updatedAt: item.updatedAt
},
update: {
title: item.title,
description: item.description,
isCompleted: item.isCompleted,
status: item.status,
priority: item.priority,
metadata: item.metadata || {},
dueDate: item.dueDate,
updatedAt: new Date()
}
});
}
public async deleteItem(id: string): Promise<boolean> {
const res = await (prisma as any).listItem.delete({
where: { id }
});
return !!res;
}
private mapToDomain(record: any): NoteList {
const items = (record.items || []).map((i: any) => new ListItem({
id: i.id,
listId: i.listId,
title: i.title,
description: i.description,
isCompleted: i.isCompleted,
status: i.status,
priority: i.priority,
metadata: i.metadata,
dueDate: i.dueDate,
createdAt: i.createdAt,
updatedAt: i.updatedAt
}));
return new NoteList({
id: record.id,
title: record.title,
category: record.category,
description: record.description,
color: record.color,
icon: record.icon,
ownerId: record.ownerId,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
items
});
}
}
+138
View File
@@ -0,0 +1,138 @@
/**
* XFlow AI Utility — OpenRouter & Google Gemini Integration (Updated for May 2026 Models)
*/
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1/chat/completions'
/** Доступные модели с описанием для выбора в UI */
export const AI_MODELS = {
/** Сложные инженерные и архитектурные задачи, высокая стабильность */
laguna: 'poolside/laguna-m.1:free',
/** MoE 120B тяжёлая аналитика, архитектурный анализ, сложные рассуждения */
nemotron: 'nvidia/nemotron-3-super-120b-a12b:free',
/** Прямой вызов Gemini 3.5 Flash */
geminiFlash: 'gemini-3.5-flash',
/** Прямой вызов Gemini 3.1 Pro Preview */
geminiPro: 'gemini-3.1-pro-preview',
/** Прямой вызов Gemini 3.1 Flash Lite */
geminiLite: 'gemini-3.1-flash-lite'
} as const
export type AiModelKey = keyof typeof AI_MODELS
/** Дефолтная модель, если не указана (мыслящая Gemini 3.1 Pro) */
const DEFAULT_MODEL: AiModelKey = 'geminiPro'
export const generateAIResponse = async (
prompt: string,
systemPrompt = 'You are a helpful assistant.',
modelKey?: AiModelKey | string,
): Promise<string> => {
// Получаем ключи из окружения
const openRouterKey = process.env.NUXT_OPEN_ROUTER_API_KEY || process.env.OPEN_ROUTER_API_KEY
const geminiKey = process.env.GEMINI_API_KEY || process.env.NUXT_GEMINI_API_KEY || process.env.GOOGLE_API_KEY
// Резолвим модель: принимаем как ключ ('laguna') так и полное имя
const resolvedModel: string =
modelKey && modelKey in AI_MODELS
? AI_MODELS[modelKey as AiModelKey]
: modelKey && (modelKey.includes('/') || modelKey.startsWith('gemini'))
? modelKey
: AI_MODELS[DEFAULT_MODEL]
// Определяем, использовать ли Gemini напрямую
const useGeminiDirect = resolvedModel.startsWith('gemini') || (!openRouterKey && geminiKey)
if (useGeminiDirect) {
if (!geminiKey) {
throw new Error('Gemini API key (GEMINI_API_KEY or GOOGLE_API_KEY) is not configured')
}
const geminiModel = resolvedModel.startsWith('gemini') ? resolvedModel : 'gemini-3.5-flash'
console.log(`[AI] Direct Google Gemini call: ${geminiModel}`)
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiKey}`
const response = await $fetch<any>(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: {
contents: [
{
role: 'user',
parts: [{ text: prompt }]
}
],
systemInstruction: {
parts: [{ text: systemPrompt }]
},
generationConfig: {
temperature: 0.3
}
}
})
const content = response?.candidates?.[0]?.content?.parts?.[0]?.text
if (!content) {
throw new Error('Empty response from Gemini API')
}
return content
} catch (error: any) {
console.error(`[AI] Gemini direct error:`, error?.data || error?.message)
throw new Error(`Gemini direct generation failed: ${error?.message || error?.toString()}`)
}
}
// Иначе используем OpenRouter
if (!openRouterKey) {
throw new Error('Neither OPEN_ROUTER_API_KEY nor GEMINI_API_KEY is configured')
}
console.log(`[AI] OpenRouter call: ${resolvedModel}`)
try {
const response = await $fetch<any>(OPENROUTER_BASE_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://a-crm.controlsystem.kz',
'X-Title': 'XFlow AI Node',
},
body: {
model: resolvedModel,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: prompt },
],
stream: false,
},
})
const content = response?.choices?.[0]?.message?.content
if (!content) {
throw new Error('Empty response from OpenRouter model')
}
return content
} catch (error: any) {
console.error(`[AI] OpenRouter error with model ${resolvedModel}:`, error?.data || error?.message)
throw new Error(`OpenRouter generation failed (${resolvedModel}): ${error?.message}`)
}
}
/** Хелпер: список моделей для фронтенда */
export const getAvailableModels = () =>
Object.entries(AI_MODELS).map(([key, modelId]) => ({
key,
modelId,
label: modelLabels[key as AiModelKey],
}))
const modelLabels: Record<AiModelKey, string> = {
laguna: '🏊 Laguna M.1 — стабильная инженерная (Claude via OpenRouter)',
nemotron: '🔬 Nemotron Super 120B — тяжёлая аналитика (NVIDIA via OpenRouter)',
geminiFlash: '⚡ Gemini 3.5 Flash — быстрая и стабильная (Google Direct)',
geminiPro: '🧠 Gemini 3.1 Pro — продвинутая аналитика (Google Direct)',
geminiLite: '🍃 Gemini 3.1 Flash Lite — экономичная (Google Direct)'
}
+61
View File
@@ -0,0 +1,61 @@
import jwt from 'jsonwebtoken'
import { H3Event, createError } from 'h3'
export interface AuthUser {
id: string
email: string
username: string
displayName?: string
role?: string
}
export function generateToken(user: AuthUser): string {
const config = useRuntimeConfig()
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
return jwt.sign(
{
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
role: user.role || 'user'
},
secret,
{ expiresIn: '7d' }
)
}
export function verifyToken(token: string): AuthUser {
const config = useRuntimeConfig()
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
return jwt.verify(token, secret) as AuthUser
}
export function getAuthUser(event: H3Event): AuthUser | null {
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.split(' ')[1]
if (!token) return null
try {
return verifyToken(token)
} catch {
return null
}
}
export function requireAuth(event: H3Event): AuthUser {
const user = getAuthUser(event)
if (!user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
return user
}
export function requireRole(event: H3Event, role: string): AuthUser {
const user = requireAuth(event)
if (user.role !== role) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user
}
+403
View File
@@ -0,0 +1,403 @@
import type { AuthUser } from './auth'
export interface CanvasNode {
id: string
type: string
x: number
y: number
width: number
height: number
text?: string
file?: string
frontmatter?: Record<string, any>
state?: 'pending' | 'in_progress' | 'completed'
label?: string
assigned_to?: string
alarm?: string
script_id?: string
prompt?: string
url?: string
method?: string
repo?: string
branch?: string
message?: string
style?: Record<string, string>
}
export interface CanvasEdge {
id: string
fromNode: string
fromSide: string
toNode: string
toSide: string
}
export interface CanvasData {
id: string
name: string
nodes: CanvasNode[]
edges: CanvasEdge[]
createdAt: string
updatedAt: string
ownerId: string
groupId?: string
isPublic?: boolean
}
export interface UserGroup {
userId: string
groupId: string
role: 'admin' | 'member' | 'viewer'
joinedAt: string
}
export interface Group {
id: string
name: string
description: string
isPublic: boolean
createdAt: string
updatedAt: string
ownerId: string
}
export interface Profile {
userId: string
displayName: string
bio: string
avatar: string
joinedAt: string
canvasCount: number
followerCount: number
followingCount: number
}
export interface Activity {
id: string
userId: string
action: string
targetId: string
targetType: string
timestamp: string
metadata?: Record<string, any>
}
// In-memory stores
export const users = new Map<string, { password: string } & AuthUser>()
export const nodes = new Map<string, any>()
export const achievements = new Map<string, any[]>()
export const pushSubscriptions = new Map<string, any>()
export const canvases = new Map<string, CanvasData>()
export const userGroups = new Map<string, UserGroup>()
export const groups = new Map<string, Group>()
export const profiles = new Map<string, Profile>()
export const activities = new Map<string, Activity>()
export const follows = new Map<string, { followerId: string, followingId: string, since: string }>()
// Seed admins
const seedAdmins = [
{ id: '1', email: 'admin@xflow.local', username: 'admin' },
{ id: '2', email: 'almalexyz@gmail.com', username: 'almalexyz' },
{ id: '3', email: 'ormix16@gmail.com', username: 'ormix16' }
]
for (const admin of seedAdmins) {
users.set(admin.email, {
...admin,
password: 'admin123',
role: 'admin'
})
// Create profiles for admins
profiles.set(admin.id, {
userId: admin.id,
displayName: admin.username,
bio: 'XFlow Administrator',
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${admin.id}`,
joinedAt: new Date().toISOString(),
canvasCount: 0,
followerCount: 0,
followingCount: 0
})
}
// Create demo groups
const demoGroups: Group[] = [
{ id: 'group-1', name: 'XFlow Team', description: 'Core development team', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
{ id: 'group-2', name: 'Beta Testers', description: 'Early access users', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
{ id: 'group-3', name: 'AI Research', description: 'AI/LLM integration research', isPublic: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '2' }
]
demoGroups.forEach(group => {
groups.set(group.id, group)
// Add admins to groups
seedAdmins.forEach(admin => {
userGroups.set(`${admin.id}-${group.id}`, {
userId: admin.id,
groupId: group.id,
role: admin.id === '1' ? 'admin' : 'member',
joinedAt: new Date().toISOString()
})
})
})
// Seed comprehensive XFlow project mind map
const xflowProjectCanvas: CanvasData = {
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
nodes: [
// Core Architecture
{
id: 'arch-1',
type: 'text',
x: 100,
y: 100,
width: 320,
height: 180,
text: '# 🏗️ Основная Архитектура\n\n- **Nuxt 3** - Vue 3 фреймворк\n- **TailwindCSS** - Стилизация\n- **Vue Flow** - Движок канваса\n- **Firebase** - Аутентификация\n- **In-memory DB** - Хранение данных\n- **PWA** - Progressive Web App',
style: { background: '#1e293b', border: '#3b82f6' }
},
// Frontend Components
{
id: 'fe-1',
type: 'text',
x: 500,
y: 50,
width: 280,
height: 160,
text: '# 🎨 Фронтенд\n\n- **FlowEditor.vue** - Основной редактор\n- **FlowNodeRenderer.vue** - Отображение узлов\n- **CanvasViewer.vue** - Устаревший просмотр\n- **Админ Панель** - Управление интерфейсом\n- **Система Аутентификации** - Вход через Google',
style: { background: '#1e293b', border: '#10b981' }
},
// Backend APIs
{
id: 'be-1',
type: 'text',
x: 500,
y: 250,
width: 280,
height: 160,
text: '# ⚙️ Backend API\n\n- `/api/canvas/*` - CRUD операций с канвасами\n- `/api/nodes/*` - Управление узлами\n- `/api/auth/*` - Аутентификация\n- `/api/admin/*` - Административные операции\n- `/api/push/*` - Уведомления',
style: { background: '#1e293b', border: '#f59e0b' }
},
// Node Types
{
id: 'nodes-1',
type: 'text',
x: 100,
y: 320,
width: 320,
height: 200,
text: '# 📦 Типы Узлов\n\n- **Текстовая Заметка** - Markdown контент\n- **Ссылка на Файл** - Ссылки на файлы\n- **Узел Задачи** - Отслеживание состояния\n- **AI/LLM Узел** - AI обработка\n- **Webhook Узел** - HTTP триггеры\n- **Git Sync Узел** - Контроль версий',
style: { background: '#1e293b', border: '#8b5cf6' }
},
// Features
{
id: 'feat-1',
type: 'text',
x: 850,
y: 150,
width: 300,
height: 220,
text: '# ✨ Ключевые Функции\n\n- **Редактор в стиле Node-RED**\n - Палитра сбоку\n - Панель свойств\n - Drag & drop\n - Соединения проводами\n\n- **Совместимость с Obsidian**\n - Экспорт в .canvas формат\n - Импорт существующих карт\n - Поддержка Markdown\n\n- **Социальные Функции**\n - Группы пользователей\n - Общие канвасы\n - Лента активности',
style: { background: '#1e293b', border: '#ef4444' }
},
// Tech Stack
{
id: 'tech-1',
type: 'text',
x: 100,
y: 560,
width: 320,
height: 180,
text: '# 🛠️ Технологический Стек\n\n```json\n{\n "frontend": "Vue 3 + Nuxt 3",\n "styling": "TailwindCSS",\n "canvas": "Vue Flow",\n "auth": "Firebase Google",\n "storage": "In-memory",\n "pwa": "@vite-pwa/nuxt"\n}\n```',
style: { background: '#1e293b', border: '#06b6d4' }
},
// Future Plans
{
id: 'future-1',
type: 'text',
x: 500,
y: 450,
width: 280,
height: 180,
text: '# 🚀 Будущее Развитие\n\n- **Git Интеграция**\n - Commit/push/pull карт\n - История версий\n - Управление ветками\n\n- **AI Интеграция**\n - OpenClaw агенты\n - Автоматизация LLM узлов\n - Умные предложения\n\n- **Социальная Сеть**\n - Профили пользователей\n - Система подписок\n - Ленты активности',
style: { background: '#1e293b', border: '#ec4899' }
},
// Database Schema
{
id: 'db-1',
type: 'file',
x: 850,
y: 400,
width: 300,
height: 160,
file: 'app/server/utils/db.ts',
text: '## Схема Базы Данных\n\n- `users` - Пользовательские аккаунты\n- `canvases` - Ментальные карты\n- `nodes` - Отдельные узлы\n- `groups` - Группы пользователей\n- `profiles` - Профили пользователей\n- `activities` - Лента активности',
style: { background: '#1e293b', border: '#84cc16' }
},
// Deployment
{
id: 'deploy-1',
type: 'text',
x: 850,
y: 590,
width: 300,
height: 140,
text: '# 🚀 Развертывание\n\n- **Docker Compose**\n- **Nginx** - Обратный прокси\n- **Gitea** - Git сервер\n- **PostgreSQL** - База данных\n- **Certbot** - SSL сертификаты',
style: { background: '#1e293b', border: '#f97316' }
},
// Social Features
{
id: 'social-1',
type: 'text',
x: 100,
y: 780,
width: 320,
height: 160,
text: '# 👥 Social Network\n\n- **User Profiles**\n - Avatar & bio\n - Canvas statistics\n - Following/followers\n\n- **Groups System**\n - Public/private groups\n - Role-based permissions\n - Shared canvases',
style: { background: '#1e293b', border: '#a855f7' }
},
// File Structure
{
id: 'files-1',
type: 'file',
x: 500,
y: 660,
width: 280,
height: 140,
file: 'project-structure.md',
text: '## Project Structure\n\n```\napp/\n├── components/\n│ ├── FlowEditor.vue\n│ └── FlowNodeRenderer.vue\n├── pages/\n│ ├── canvas.vue\n│ └── admin.vue\n└── server/api/\n```',
style: { background: '#1e293b', border: '#0ea5e9' }
}
],
edges: [
{ id: 'e1', fromNode: 'arch-1', fromSide: 'right', toNode: 'fe-1', toSide: 'left' },
{ id: 'e2', fromNode: 'arch-1', fromSide: 'right', toNode: 'be-1', toSide: 'left' },
{ id: 'e3', fromNode: 'arch-1', fromSide: 'bottom', toNode: 'nodes-1', toSide: 'top' },
{ id: 'e4', fromNode: 'fe-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
{ id: 'e5', fromNode: 'be-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
{ id: 'e6', fromNode: 'nodes-1', fromSide: 'bottom', toNode: 'tech-1', toSide: 'top' },
{ id: 'e7', fromNode: 'be-1', fromSide: 'bottom', toNode: 'future-1', toSide: 'top' },
{ id: 'e8', fromNode: 'feat-1', fromSide: 'bottom', toNode: 'db-1', toSide: 'top' },
{ id: 'e9', fromNode: 'db-1', fromSide: 'bottom', toNode: 'deploy-1', toSide: 'top' },
{ id: 'e10', fromNode: 'tech-1', fromSide: 'right', toNode: 'future-1', toSide: 'left' },
{ id: 'e11', fromNode: 'tech-1', fromSide: 'bottom', toNode: 'social-1', toSide: 'top' },
{ id: 'e12', fromNode: 'future-1', fromSide: 'bottom', toNode: 'files-1', toSide: 'top' },
{ id: 'e13', fromNode: 'social-1', fromSide: 'right', toNode: 'files-1', toSide: 'left' }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: '1',
isPublic: true
}
canvases.set('xflow-project', xflowProjectCanvas)
// Create shared canvases for groups
const sharedCanvases = [
{ id: 'team-roadmap', name: 'Team Roadmap 2025', groupId: 'group-1', ownerId: '1' },
{ id: 'beta-feedback', name: 'Beta Feedback Map', groupId: 'group-2', ownerId: '1' },
{ id: 'ai-research', name: 'AI Integration Ideas', groupId: 'group-3', ownerId: '2' }
]
sharedCanvases.forEach(canvas => {
const canvasData: CanvasData = {
id: canvas.id,
name: canvas.name,
nodes: [
{
id: `${canvas.id}-node-1`,
type: 'text',
x: 200,
y: 200,
width: 300,
height: 150,
text: `# ${canvas.name}\n\nThis is a shared canvas for the group.\n\nStart adding your ideas!`,
style: { background: '#1e293b', border: '#3b82f6' }
}
],
edges: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: canvas.ownerId,
groupId: canvas.groupId,
isPublic: true
}
canvases.set(canvas.id, canvasData)
})
// Add demo activities
const demoActivities: Activity[] = [
{ id: 'activity-1', userId: '1', action: 'created_canvas', targetId: 'xflow-project', targetType: 'canvas', timestamp: new Date().toISOString() },
{ id: 'activity-2', userId: '2', action: 'joined_group', targetId: 'group-1', targetType: 'group', timestamp: new Date().toISOString() },
{ id: 'activity-3', userId: '3', action: 'created_canvas', targetId: 'ai-research', targetType: 'canvas', timestamp: new Date().toISOString() },
{ id: 'activity-4', userId: '1', action: 'followed_user', targetId: '2', targetType: 'user', timestamp: new Date().toISOString() }
]
demoActivities.forEach(activity => {
activities.set(activity.id, activity)
})
// Add demo follows
follows.set('1-2', { followerId: '1', followingId: '2', since: new Date().toISOString() })
follows.set('2-3', { followerId: '2', followingId: '3', since: new Date().toISOString() })
// Keep the old demo canvas for backward compatibility
canvases.set('demo', {
id: 'demo',
name: 'Demo Canvas',
nodes: [
{
id: 'node-1',
type: 'text',
x: 0,
y: 0,
width: 300,
height: 200,
text: '# Welcome\n\nЭто демо-нода с **Markdown** поддержкой.',
state: 'pending',
style: { background: '#1e293b', border: '#334155' }
},
{
id: 'node-2',
type: 'file',
x: 400,
y: 0,
width: 300,
height: 200,
file: 'tasks.md',
frontmatter: { alarm: '2025-04-24T09:00:00Z', script_id: 'notify', assigned_to: 'user' },
state: 'pending',
style: { background: '#1e293b', border: '#334155' }
},
{
id: 'node-3',
type: 'text',
x: 200,
y: 300,
width: 300,
height: 150,
text: '### Завершенная задача\n- [x] Настроить PWA',
state: 'completed',
style: { background: '#064e3b', border: '#059669' }
}
],
edges: [
{ id: 'edge-1', fromNode: 'node-1', fromSide: 'right', toNode: 'node-2', toSide: 'left' },
{ id: 'edge-2', fromNode: 'node-2', fromSide: 'bottom', toNode: 'node-3', toSide: 'top' }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: '1'
})
export function getUserAchievements(userId: string) {
return achievements.get(userId) || []
}
export function addAchievement(userId: string, achievement: any) {
const list = achievements.get(userId) || []
list.push({ ...achievement, unlockedAt: new Date().toISOString() })
achievements.set(userId, list)
}
+2
View File
@@ -0,0 +1,2 @@
// Deprecated: replaced by giteaApi.ts
export { }
+160
View File
@@ -0,0 +1,160 @@
// Gitea API integration for XFlow canvas sync
// Structure:
// Personal canvases → {username}/MM (personal user repo, created on first save)
// Group canvases → AVTEX/group-{groupId} (created on group creation)
// Public canvases → AVTEX/MMAVTEX (shared org repo)
interface GiteaFile {
content: string // base64
sha?: string
}
const getConfig = () => {
const config = useRuntimeConfig()
return {
url: config.giteaUrl,
token: config.giteaToken,
org: config.giteaOrg,
mindmapRepo: config.giteaMindmapRepo,
projectRepo: config.giteaProjectRepo,
}
}
const apiHeaders = (token: string) => ({
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
})
// Resolve which repo to use based on canvas ownership
// Returns { owner, repo } to support both org and personal repos
export function resolveRepo(opts: { username?: string; groupId?: string; isPublic?: boolean }): { owner: string; repo: string } {
const { org, mindmapRepo } = getConfig()
if (opts.groupId) return { owner: org, repo: `group-${opts.groupId}` }
if (opts.username && !opts.isPublic) return { owner: opts.username, repo: 'MM' }
return { owner: org, repo: mindmapRepo }
}
// Ensure repository exists, create if not (supports personal and org repos)
export async function ensureRepo(owner: string, repoName: string, description = ''): Promise<boolean> {
const { url, token, org } = getConfig()
const h = apiHeaders(token)
// Check if exists
try {
await $fetch(`${url}/api/v1/repos/${owner}/${repoName}`, { headers: h })
return true
} catch { }
// Create — personal repo or org repo
try {
const isOrg = owner === org
const endpoint = isOrg
? `${url}/api/v1/orgs/${org}/repos`
: `${url}/api/v1/user/repos`
await $fetch(endpoint, {
method: 'POST',
headers: h,
body: {
name: repoName,
description: description || `XFlow canvas repo: ${repoName}`,
private: true,
auto_init: true,
default_branch: 'main'
}
})
return true
} catch (e: any) {
console.error(`Gitea ensureRepo(${owner}/${repoName}) error:`, e.data || e)
throw new Error(`ensureRepo failed: ${e.message}`)
}
}
// Get file from Gitea (returns null if not found)
export async function giteaGetFile(path: string, owner?: string, repo?: string): Promise<GiteaFile | null> {
const { url, token, org, mindmapRepo } = getConfig()
const repoOwner = owner || org
const repoName = repo || mindmapRepo
try {
const res = await $fetch<any>(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/${path}`, {
headers: apiHeaders(token)
})
return { content: res.content, sha: res.sha }
} catch {
return null
}
}
// Create or update file in Gitea
export async function giteaPutFile(path: string, content: string, message: string, sha?: string, owner?: string, repo?: string): Promise<boolean> {
const { url, token, org, mindmapRepo } = getConfig()
const repoOwner = owner || org
const repoName = repo || mindmapRepo
const encoded = Buffer.from(content).toString('base64')
try {
await $fetch(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/${path}`, {
method: sha ? 'PUT' : 'POST',
headers: apiHeaders(token),
body: {
message,
content: encoded,
...(sha ? { sha } : {})
}
})
return true
} catch (e: any) {
console.error('Gitea putFile error:', e.data || e)
throw new Error(`giteaPutFile failed: ${e.message}`)
}
}
// Save canvas to correct repo based on ownership
export async function saveCanvasToGitea(
canvasId: string,
canvasData: any,
opts: { username?: string; groupId?: string; isPublic?: boolean } = {},
commitMessage?: string
): Promise<boolean> {
const { owner, repo } = resolveRepo(opts)
const path = `${canvasId}.canvas`
const content = JSON.stringify(canvasData, null, 2)
const message = commitMessage || `Update ${canvasId} at ${new Date().toLocaleString('ru-RU')}`
await ensureRepo(owner, repo)
const existing = await giteaGetFile(path, owner, repo)
return giteaPutFile(path, content, message, existing?.sha, owner, repo)
}
// Load canvas from Gitea
export async function loadCanvasFromGitea(
canvasId: string,
opts: { username?: string; groupId?: string; isPublic?: boolean } = {}
): Promise<any | null> {
const { owner, repo } = resolveRepo(opts)
const file = await giteaGetFile(`${canvasId}.canvas`, owner, repo)
if (!file) return null
try {
return JSON.parse(Buffer.from(file.content, 'base64').toString('utf-8'))
} catch {
return null
}
}
// List canvases in a repo
export async function listCanvasesFromGitea(owner?: string, repo?: string): Promise<string[]> {
const { url, token, org, mindmapRepo } = getConfig()
const repoOwner = owner || org
const repoName = repo || mindmapRepo
try {
const res = await $fetch<any[]>(`${url}/api/v1/repos/${repoOwner}/${repoName}/contents/`, {
headers: apiHeaders(token)
})
return res
.filter((f: any) => f.name.endsWith('.canvas'))
.map((f: any) => f.name.replace('.canvas', ''))
} catch {
return []
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
import PrismaPkg from '@prisma/client'
const PrismaClient = PrismaPkg.PrismaClient
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
const adapter = new PrismaPg(pool)
const globalForPrisma = globalThis as unknown as { prisma: InstanceType<typeof PrismaClient> }
export const prisma = globalForPrisma.prisma || new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+264
View File
@@ -0,0 +1,264 @@
// Real Git integration utilities for XFlow with Gitea
// This handles actual Git operations, caching, and change tracking
import { execSync } from 'child_process'
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
export interface GitRepository {
id: string
name: string
owner: string
url: string
cloneUrl: string
isPrivate: boolean
type: 'personal' | 'group'
ownerId: string
localPath: string
createdAt: string
updatedAt: string
}
export interface GitCommit {
id: string
message: string
author: string
timestamp: string
canvasId: string
changes: {
added: string[]
modified: string[]
deleted: string[]
}
}
// Cache for Git repositories
const gitCache = new Map<string, GitRepository>()
const canvasCache = new Map<string, any>()
// Base path for local Git repositories
const GIT_BASE_PATH = process.env.GIT_BASE_PATH || '/tmp/xflow-git'
// Ensure base directory exists
if (!existsSync(GIT_BASE_PATH)) {
mkdirSync(GIT_BASE_PATH, { recursive: true })
}
// Create a new Git repository for a user
export async function createPersonalRepository(userId: string, canvasId: string): Promise<GitRepository> {
const repoId = `repo-${userId}-${canvasId}-${Date.now()}`
const repoName = `canvas-${canvasId}`
const localPath = join(GIT_BASE_PATH, userId, repoName)
// Create local directory
if (!existsSync(localPath)) {
mkdirSync(localPath, { recursive: true })
}
// Initialize Git repository
try {
execSync('git init', { cwd: localPath })
execSync('git config user.name "XFlow System"', { cwd: localPath })
execSync('git config user.email "xflow@system.local"', { cwd: localPath })
} catch (error) {
console.error('Failed to initialize Git repository:', error)
}
const repository: GitRepository = {
id: repoId,
name: repoName,
owner: userId,
url: `https://gitea.example.com/${userId}/${repoName}`,
cloneUrl: `https://gitea.example.com/${userId}/${repoName}.git`,
isPrivate: true,
type: 'personal',
ownerId: userId,
localPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
gitCache.set(repoId, repository)
// Initialize with initial commit
await initializeRepository(repository, canvasId)
return repository
}
// Initialize repository with initial canvas data
async function initializeRepository(repository: GitRepository, canvasId: string): Promise<void> {
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
// Create initial canvas file
const initialCanvas = {
nodes: [],
edges: []
}
writeFileSync(canvasFile, JSON.stringify(initialCanvas, null, 2))
// Create initial commit
try {
execSync('git add .', { cwd: repository.localPath })
execSync(`git commit -m "Initial commit for canvas ${canvasId}"`, { cwd: repository.localPath })
} catch (error) {
console.error('Failed to create initial commit:', error)
}
}
// Sync canvas changes to Git repository
export async function syncCanvasToGit(
canvasId: string,
canvasData: any,
commitMessage?: string
): Promise<GitCommit> {
// Find repository for this canvas
const repository = Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId))
if (!repository) {
throw new Error(`No Git repository found for canvas ${canvasId}`)
}
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
// Check if file has changed
const currentContent = JSON.stringify(canvasData, null, 2)
let hasChanges = true
if (existsSync(canvasFile)) {
const existingContent = readFileSync(canvasFile, 'utf-8')
hasChanges = existingContent !== currentContent
}
if (!hasChanges) {
// Return latest commit if no changes
const commits = getRepositoryHistory(repository)
return commits[0] || {
id: 'no-changes',
message: 'No changes detected',
author: 'system',
timestamp: new Date().toISOString(),
canvasId,
changes: { added: [], modified: [], deleted: [] }
}
}
// Write canvas data
writeFileSync(canvasFile, currentContent)
// Commit changes
const message = commitMessage || `Update canvas ${canvasId}`
try {
execSync('git add .', { cwd: repository.localPath })
execSync(`git commit -m "${message}"`, { cwd: repository.localPath })
} catch (error) {
console.error('Failed to commit changes:', error)
}
// Update cache
repository.updatedAt = new Date().toISOString()
canvasCache.set(canvasId, canvasData)
// Get commit hash
let commitHash = 'unknown'
try {
commitHash = execSync('git rev-parse HEAD', { cwd: repository.localPath, encoding: 'utf-8' }).trim()
} catch (error) {
console.error('Failed to get commit hash:', error)
}
const commit: GitCommit = {
id: commitHash,
message,
author: repository.owner,
timestamp: new Date().toISOString(),
canvasId,
changes: {
added: existsSync(canvasFile) ? [`${canvasId}.canvas`] : [],
modified: [`${canvasId}.canvas`],
deleted: []
}
}
return commit
}
// Load canvas from Git repository
export async function loadCanvasFromGit(canvasId: string): Promise<any> {
// Check cache first
if (canvasCache.has(canvasId)) {
return canvasCache.get(canvasId)
}
// Find repository for this canvas
const repository = Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId))
if (!repository) {
throw new Error(`No Git repository found for canvas ${canvasId}`)
}
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
if (!existsSync(canvasFile)) {
throw new Error(`Canvas file not found: ${canvasFile}`)
}
const canvasData = JSON.parse(readFileSync(canvasFile, 'utf-8'))
// Cache the data
canvasCache.set(canvasId, canvasData)
return canvasData
}
// Get repository for a canvas
export function getCanvasRepository(canvasId: string): GitRepository | null {
return Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId)) || null
}
// Get commit history for a repository
export function getRepositoryHistory(repository: GitRepository): GitCommit[] {
try {
const log = execSync('git log --pretty=format:"%H|%s|%an|%ad" --date=iso', {
cwd: repository.localPath,
encoding: 'utf-8'
})
return log.split('\n')
.filter(line => line.trim())
.map(line => {
const [id, message, author, timestamp] = line.split('|')
return {
id,
message,
author,
timestamp,
canvasId: repository.name.replace('canvas-', ''),
changes: { added: [], modified: [], deleted: [] }
}
})
} catch (error) {
console.error('Failed to get repository history:', error)
return []
}
}
// Check if canvas has unsaved changes
export function hasUnsavedChanges(canvasId: string, currentData: any): boolean {
if (!canvasCache.has(canvasId)) {
return true // No cached data, assume changes
}
const cachedData = canvasCache.get(canvasId)
return JSON.stringify(cachedData) !== JSON.stringify(currentData)
}
// Clear cache for a canvas
export function clearCanvasCache(canvasId: string): void {
canvasCache.delete(canvasId)
}