diff --git a/app/components/FlowEditor.vue b/app/components/FlowEditor.vue index df93453..07e8983 100644 --- a/app/components/FlowEditor.vue +++ b/app/components/FlowEditor.vue @@ -23,7 +23,19 @@
- + + + +
+ Группа: + +
+ ● Несохранено @@ -204,6 +216,8 @@ const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value} const elements = ref([]) const selectedNode = ref(null) const canvasName = ref(props.canvasId) +const selectedGroupId = ref(null) +const groupsList = ref([]) const nodeTypes = [ { type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' }, @@ -219,6 +233,7 @@ const loadData = async () => { const data = await loadCanvas(props.canvasId) elements.value = transformToFlowElements(data) canvasName.value = data.name || props.canvasId + selectedGroupId.value = data.groupId || null // Mark initial state as saved markAsSaved(data) } @@ -639,7 +654,7 @@ const saveCanvas = async () => { console.log('[DEBUG saveCanvas] Sending PUT request...') const res = await $fetch(`/api/canvas/${props.canvasId}`, { method: 'PUT', - body: { ...obsidianCanvas, name: canvasName.value }, + body: { ...obsidianCanvas, name: canvasName.value, groupId: selectedGroupId.value }, headers: authHeaders.value }) console.log('[DEBUG saveCanvas] PUT request successful!', res) @@ -690,6 +705,14 @@ onMounted(async () => { } catch (e) { console.error('Failed to load canvases', e) } + + // Load groups for assignment + try { + const data = await $fetch('/api/groups') + groupsList.value = data.groups || [] + } catch (e) { + console.error('Failed to load groups', e) + } }) diff --git a/app/components/InstallPWA.vue b/app/components/InstallPWA.vue new file mode 100644 index 0000000..044750e --- /dev/null +++ b/app/components/InstallPWA.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index b58e2b2..f429c6a 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -5,6 +5,7 @@ interface User { id: string email: string username: string + displayName?: string role?: string } diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 4018d04..a5edab2 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -11,8 +11,8 @@ Настройки
- - {{ user?.username }} + + {{ user?.displayName || user?.username }}
diff --git a/app/middleware/admin.ts b/app/middleware/admin.ts index 3cdead5..f2c6551 100644 --- a/app/middleware/admin.ts +++ b/app/middleware/admin.ts @@ -1,15 +1,15 @@ export default defineNuxtRouteMiddleware((to, from) => { const token = useCookie('xflow_token') - if (!token.value) { - return navigateTo('/login') - } + + if (!token.value) return navigateTo('/login') try { - const payload = JSON.parse(atob(token.value.split('.')[1])) - if (payload.role !== 'admin') { - return navigateTo('/') - } - } catch { + const base64Url = token.value.split('.')[1] + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const payload = JSON.parse(window.atob(base64)) + + if (payload.role !== 'admin') return navigateTo('/') + } catch (e) { return navigateTo('/login') } }) diff --git a/app/pages/admin.vue b/app/pages/admin.vue index 2873b6d..8f62b8b 100644 --- a/app/pages/admin.vue +++ b/app/pages/admin.vue @@ -1,174 +1,3 @@ - - diff --git a/app/pages/admin/groups.vue b/app/pages/admin/groups.vue index cdba772..8f62b8b 100644 --- a/app/pages/admin/groups.vue +++ b/app/pages/admin/groups.vue @@ -1,150 +1,3 @@ - - diff --git a/app/pages/admin/groups/[id].vue b/app/pages/admin/groups/[id].vue new file mode 100644 index 0000000..31c11d1 --- /dev/null +++ b/app/pages/admin/groups/[id].vue @@ -0,0 +1,81 @@ + + + diff --git a/app/pages/admin/groups/index.vue b/app/pages/admin/groups/index.vue new file mode 100644 index 0000000..d56bd96 --- /dev/null +++ b/app/pages/admin/groups/index.vue @@ -0,0 +1,173 @@ + + + diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue new file mode 100644 index 0000000..88778db --- /dev/null +++ b/app/pages/admin/index.vue @@ -0,0 +1,153 @@ + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index 69667da..235467b 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -15,41 +15,57 @@

Dashboard

🗺️
-

Canvas

-

Визуальное управление задачами

+

Проекты

+

{{ homeStats.canvases }} канвасов доступно

✅

Задачи

-

{{ completedTasks }} / {{ totalTasks }} выполнено

+

{{ homeStats.tasks.completed }} / {{ homeStats.tasks.total }} выполнено

🏆

Ачивки

-

{{ achievements }} разблокировано

+

{{ homeStats.achievements }} разблокировано

+ + + diff --git a/app/pages/login.vue b/app/pages/login.vue index 504255e..a398698 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -33,7 +33,7 @@ definePageMeta({ layout: 'default' }) -const { googleSignIn, initAuth } = useAuth() +const { googleSignIn, initAuth, isAuthenticated } = useAuth() const loading = ref(false) const signInWithGoogle = async () => { @@ -49,6 +49,13 @@ const signInWithGoogle = async () => { } onMounted(() => { + if (isAuthenticated.value) { + navigateTo('/') + } initAuth() }) + +watch(isAuthenticated, (val) => { + if (val) navigateTo('/') +}) diff --git a/app/pages/profile.vue b/app/pages/profile.vue index 2d2c230..cbfc9f0 100644 --- a/app/pages/profile.vue +++ b/app/pages/profile.vue @@ -223,6 +223,11 @@ const loadProfile = async () => { profile.value = profileData.profile userCanvases.value = canvasesData.canvases userActivities.value = activitiesData.activities + + // Redirect to pretty URL if using UID but username exists + if (profile.value.user?.username && route.params.id === profile.value.userId) { + navigateTo(`/profile/${profile.value.user.username}`, { replace: true }) + } if (isOwnProfile.value) { const [groupsData, allGroupsData] = await Promise.all([ diff --git a/app/pages/settings.vue b/app/pages/settings.vue index 3552b1a..b75d50e 100644 --- a/app/pages/settings.vue +++ b/app/pages/settings.vue @@ -3,6 +3,66 @@

Настройки

+ +
+

+ 👤 Личный профиль +

+
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +

+ {{ saveMessage.text }} +

+
+
+ + +
+

+ 👥 Мои Группы +

+
+ Вы не состоите ни в одной группе. +
+
+
+
+
{{ g.name }}
+
{{ g.description }}
+
+ + {{ g.membershipRole }} + +
+
+
+

@@ -72,10 +132,59 @@ definePageMeta({ middleware: 'auth' }) +const { user } = useAuth() const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush() const { achievements, loadAchievements } = useAchievements() const permissionGranted = ref(false) +const saving = ref(false) +const saveMessage = ref(null) +const userGroups = ref([]) + +const profileForm = reactive({ + displayName: '', + username: '', + bio: '', + avatar: '' +}) + +const loadProfile = async () => { + try { + const data = await $fetch(`/api/users/${user.value.id}/profile`, { + headers: { Authorization: `Bearer ${useAuth().token.value}` } + }) +const groupsData = await $fetch('/api/user/groups', { + headers: { Authorization: `Bearer ${useAuth().token.value}` } + }) + userGroups.value = groupsData.groups || [] + + profileForm.displayName = data.profile.displayName || '' + profileForm.username = data.profile.user.username || '' + profileForm.bio = data.profile.bio || '' + profileForm.avatar = data.profile.avatar || '' + } catch (e) { + console.error('Failed to load profile', e) + } +} + +const saveProfile = async () => { + saving.value = true + saveMessage.value = null + try { + await $fetch('/api/user/profile', { + method: 'PUT', + body: profileForm, + headers: { Authorization: `Bearer ${useAuth().token.value}` } + }) + saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' } + // Update local user data if needed + user.value.username = profileForm.username + } catch (e) { + saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' } + } finally { + saving.value = false + } +} const enablePush = async () => { const granted = await requestPermission() @@ -91,6 +200,9 @@ const subscribe = async () => { onMounted(async () => { permissionGranted.value = Notification.permission === 'granted' - await loadAchievements() + await Promise.all([ + loadAchievements(), + loadProfile() + ]) }) diff --git a/app/pages/tasks.vue b/app/pages/tasks.vue index 46b1e04..887736d 100644 --- a/app/pages/tasks.vue +++ b/app/pages/tasks.vue @@ -5,6 +5,56 @@

📋 Мои Задачи

+
+ +
+
+ + +
+
+

Создать новую задачу

+
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
@@ -92,6 +142,16 @@ const columnsData = reactive({ completed: [] }) +const showCreateModal = ref(false) +const creating = ref(false) +const availableCanvases = ref([]) +const newTask = reactive({ + title: '', + canvasId: '', + priority: 'medium', + state: 'todo' +}) + const columns = [ { id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' }, { id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' }, @@ -125,6 +185,36 @@ const fetchTasks = async () => { } } +const fetchCanvases = async () => { + try { + const res = await $fetch('/api/canvas') + availableCanvases.value = res.canvases || [] + if (availableCanvases.value.length > 0) { + newTask.canvasId = availableCanvases.value[0].id + } + } catch (e) { + console.error('Failed to load canvases', e) + } +} + +const createTask = async () => { + creating.value = true + try { + await $fetch('/api/tasks/create', { + method: 'POST', + body: newTask, + headers: { Authorization: `Bearer ${auth.token.value}` } + }) + showCreateModal.value = false + newTask.title = '' + await fetchTasks() // Refresh list + } catch (e) { + alert('Ошибка при создании задачи: ' + (e.data?.message || e.message)) + } finally { + creating.value = false + } +} + const onTaskMove = async (evt, newStatus) => { if (evt.added) { const task = evt.added.element @@ -197,7 +287,10 @@ onMounted(async () => { if (!auth.user.value) { await auth.init() } - await fetchTasks() + await Promise.all([ + fetchTasks(), + fetchCanvases() + ]) }) diff --git a/app/public/manifest.json b/app/public/manifest.json new file mode 100644 index 0000000..078708d --- /dev/null +++ b/app/public/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "/", + "name": "XFlow", + "short_name": "XFlow", + "description": "XFlow Obsidian Canvas PWA", + "lang": "ru", + "categories": ["productivity", "business"], + "theme_color": "#0f172a", + "background_color": "#0f172a", + "display": "standalone", + "orientation": "portrait", + "start_url": "/", + "scope": "/", + "icons": [ + { + "src": "/pwa-192x192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/pwa-512x512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/maskable-icon-512x512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/app/server/api/admin/stats.get.ts b/app/server/api/admin/stats.get.ts index 71e8ee1..3108427 100644 --- a/app/server/api/admin/stats.get.ts +++ b/app/server/api/admin/stats.get.ts @@ -1,28 +1,47 @@ import { requireAuth } from '~/server/utils/auth' -import { users, canvases, pushSubscriptions } from '~/server/utils/db' +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' }) } - const usersList = Array.from(users.values()) - const canvasesList = Array.from(canvases.values()).map(c => ({ - id: c.id, - name: c.name, - nodeCount: c.nodes.length, - updatedAt: c.updatedAt - })) + // 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, + 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.nodeCount, 0), - pushes: pushSubscriptions.size + nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0), + pushes: pushCount } } -}) +}) diff --git a/app/server/api/auth/google.post.ts b/app/server/api/auth/google.post.ts index a92bab3..e490661 100644 --- a/app/server/api/auth/google.post.ts +++ b/app/server/api/auth/google.post.ts @@ -34,12 +34,15 @@ export default defineEventHandler(async (event) => { const fbUser: FirebaseUser = verifyRes.users[0] const email = fbUser.email - const username = fbUser.displayName || email.split('@')[0] + const username = email.split('@')[0] const userId = fbUser.localId const user = await prisma.user.upsert({ where: { email }, - update: { username }, + update: { + username + // Мы не затираем профиль при каждом входе, но нам нужно его получить + }, create: { id: userId, email, @@ -48,10 +51,13 @@ export default defineEventHandler(async (event) => { role: 'user', profile: { create: { - displayName: username, + displayName: fbUser.displayName || username, avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}` } } + }, + include: { + profile: true } }) @@ -59,6 +65,7 @@ export default defineEventHandler(async (event) => { id: user.id, email: user.email, username: user.username, + displayName: user.profile?.displayName || user.username, role: user.role }) diff --git a/app/server/api/groups/[id]/add-member.post.ts b/app/server/api/groups/[id]/add-member.post.ts new file mode 100644 index 0000000..386b51f --- /dev/null +++ b/app/server/api/groups/[id]/add-member.post.ts @@ -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, role = 'member' } = body + + if (!groupId || !email) { + throw createError({ statusCode: 400, message: 'Group ID and Email 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. Ищем пользователя по email + const userToAdd = await prisma.user.findUnique({ + where: { email } + }) + + if (!userToAdd) { + throw createError({ statusCode: 404, message: 'User with this email not found in system' }) + } + + // 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' }) + } +}) diff --git a/app/server/api/groups/[id]/index.get.ts b/app/server/api/groups/[id]/index.get.ts new file mode 100644 index 0000000..0380aa9 --- /dev/null +++ b/app/server/api/groups/[id]/index.get.ts @@ -0,0 +1,36 @@ +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 + } + } + } + } + } + }) + + 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 } +}) diff --git a/app/server/api/groups/index.get.ts b/app/server/api/groups/index.get.ts index a73bbd2..63dd98e 100644 --- a/app/server/api/groups/index.get.ts +++ b/app/server/api/groups/index.get.ts @@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => { const user = requireAuth(event) const allGroups = await prisma.group.findMany({ - where: { isPublic: true }, + where: user.role === 'admin' ? {} : { isPublic: true }, include: { members: { include: { user: { select: { id: true, username: true } } } }, _count: { select: { members: true, canvases: true } } diff --git a/app/server/api/tasks/create.post.ts b/app/server/api/tasks/create.post.ts new file mode 100644 index 0000000..523ec0f --- /dev/null +++ b/app/server/api/tasks/create.post.ts @@ -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 } +}) diff --git a/app/server/api/tasks/index.get.ts b/app/server/api/tasks/index.get.ts new file mode 100644 index 0000000..deec488 --- /dev/null +++ b/app/server/api/tasks/index.get.ts @@ -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 } +}) diff --git a/app/server/api/tasks/update-status.post.ts b/app/server/api/tasks/update-status.post.ts index e9f3a07..91a6f24 100644 --- a/app/server/api/tasks/update-status.post.ts +++ b/app/server/api/tasks/update-status.post.ts @@ -7,10 +7,10 @@ export default defineEventHandler(async (event) => { const { taskId, canvasId, newState } = body if (!taskId || !canvasId || !newState) { - throw createError({ statusCode: 400, message: 'taskId, canvasId and newState are required' }) + throw createError({ statusCode: 400, message: 'Missing parameters' }) } - // 1. Найти холст + // 1. Получаем канвас const canvas = await prisma.canvas.findUnique({ where: { id: canvasId } }) @@ -19,7 +19,21 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: 'Canvas not found' }) } - // 2. Найти и обновить узел в JSON + // 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) @@ -29,18 +43,15 @@ export default defineEventHandler(async (event) => { // Обновляем состояние nodes[nodeIdx].state = newState - - // Также обновляем дату изменения, если нужно - nodes[nodeIdx].updatedAt = new Date().toISOString() + if (nodes[nodeIdx].data) { + nodes[nodeIdx].data.state = newState + } - // 3. Сохранить изменения в БД + // 4. Сохраняем обратно в БД await prisma.canvas.update({ where: { id: canvasId }, data: { nodes } }) - return { - success: true, - message: `Task ${taskId} moved to ${newState}` - } + return { success: true } }) diff --git a/app/server/api/user/profile.put.ts b/app/server/api/user/profile.put.ts new file mode 100644 index 0000000..6e9e975 --- /dev/null +++ b/app/server/api/user/profile.put.ts @@ -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 } +}) diff --git a/app/server/api/user/stats.get.ts b/app/server/api/user/stats.get.ts new file mode 100644 index 0000000..74a4805 --- /dev/null +++ b/app/server/api/user/stats.get.ts @@ -0,0 +1,51 @@ +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 as any[]) || [] + 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 + } + } +}) diff --git a/app/server/api/users/[id]/activity.get.ts b/app/server/api/users/[id]/activity.get.ts index 4fd9f62..c437b1f 100644 --- a/app/server/api/users/[id]/activity.get.ts +++ b/app/server/api/users/[id]/activity.get.ts @@ -8,10 +8,23 @@ export default defineEventHandler(async (event) => { } // Get user's recent activities - const userActivities = Array.from(activities.values()) - .filter(activity => activity.userId === userId) - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 50) // Limit to 50 most recent + 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 } }) diff --git a/app/server/api/users/[id]/canvases.get.ts b/app/server/api/users/[id]/canvases.get.ts index 23b113f..d0866b3 100644 --- a/app/server/api/users/[id]/canvases.get.ts +++ b/app/server/api/users/[id]/canvases.get.ts @@ -7,6 +7,12 @@ export default defineEventHandler(async (event) => { 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) diff --git a/app/server/api/users/[id]/follow-status.get.ts b/app/server/api/users/[id]/follow-status.get.ts new file mode 100644 index 0000000..564378e --- /dev/null +++ b/app/server/api/users/[id]/follow-status.get.ts @@ -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 } + } +}) diff --git a/app/server/api/users/[id]/follow.post.ts b/app/server/api/users/[id]/follow.post.ts new file mode 100644 index 0000000..f6f48b6 --- /dev/null +++ b/app/server/api/users/[id]/follow.post.ts @@ -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 } +}) diff --git a/app/server/api/users/[id]/profile.get.ts b/app/server/api/users/[id]/profile.get.ts index cc27332..c7f26a0 100644 --- a/app/server/api/users/[id]/profile.get.ts +++ b/app/server/api/users/[id]/profile.get.ts @@ -7,27 +7,52 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: 'User ID is required' }) } - const profile = await prisma.profile.findUnique({ - where: { userId }, + let profile = await prisma.profile.findFirst({ + where: { + OR: [ + { userId: userId }, + { user: { username: userId } } + ] + }, include: { user: { include: { _count: { - select: { - canvases: true, - followers: true, - following: true - } + select: { canvases: true, followers: true, following: true } } } } } }) + // If profile doesn't exist but user does, create it if (!profile) { - // If profile doesn't exist but user might exist in Firebase, - // we could potentially create it here or return 404 - throw createError({ statusCode: 404, message: 'Profile not found' }) + 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 { diff --git a/app/server/utils/auth.ts b/app/server/utils/auth.ts index 21b8fe0..089da3b 100644 --- a/app/server/utils/auth.ts +++ b/app/server/utils/auth.ts @@ -1,25 +1,36 @@ import jwt from 'jsonwebtoken' import { H3Event, createError } from 'h3' -const JWT_SECRET = process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key' - 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, role: user.role || 'user' }, - JWT_SECRET, + { + 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 { - return jwt.verify(token, JWT_SECRET) as 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 { diff --git a/nuxt.config.ts b/nuxt.config.ts index 3e922dc..2ed86bb 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -8,52 +8,32 @@ export default defineNuxtConfig({ '@vueuse/nuxt' ], + experimental: { + appManifest: false + }, + css: ['~/assets/css/main.css'], tailwindcss: { cssPath: '~/assets/css/main.css' }, - pwa: { - registerType: 'autoUpdate', - workbox: { - globPatterns: ['**/*.{js,css,html,png,svg,ico}'], - }, - client: { - installPrompt: true, - }, - manifest: { - name: 'XFlow', - short_name: 'XFlow', - description: 'XFlow Obsidian Canvas PWA', - theme_color: '#0f172a', - background_color: '#0f172a', - display: 'standalone', - orientation: 'portrait', - scope: '/', - start_url: '/', - icons: [ - { - src: 'pwa-192x192.svg', - sizes: '192x192', - type: 'image/svg+xml' - }, - { - src: 'pwa-512x512.svg', - sizes: '512x512', - type: 'image/svg+xml', - purpose: 'any' - }, - { - src: 'maskable-icon-512x512.svg', - sizes: '512x512', - type: 'image/svg+xml', - purpose: 'maskable' - } + app: { + head: { + title: 'XFlow', + link: [ + { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, + { rel: 'manifest', href: '/manifest.json' } ] - }, + } + }, + + pwa: { + injectRegister: 'script', + registerType: 'autoUpdate', devOptions: { - enabled: false + enabled: false, + suppressWarnings: true } }, diff --git a/prisma.config.ts b/prisma.config.ts index f96141d..1c6ce5c 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,11 +1,12 @@ import { defineConfig } from "prisma/config"; +// Призма 7 иногда требует явной загрузки .env для некоторых команд export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", }, datasource: { - url: process.env["DATABASE_URL"], + url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db", }, }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7bb2650..df0ff5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,20 +8,19 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - username String @unique - password String - role String @default("user") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - profile Profile? - canvases Canvas[] - groupMembers GroupMember[] + id String @id @default(cuid()) + email String @unique + username String @unique + password String + role String @default("user") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt activities Activity[] - followers Follow[] @relation("following") - following Follow[] @relation("follower") + canvases Canvas[] + following Follow[] @relation("follower") + followers Follow[] @relation("following") + groupMembers GroupMember[] + profile Profile? } model Profile { @@ -34,8 +33,7 @@ model Profile { followerCount Int @default(0) followingCount Int @default(0) joinedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Canvas { @@ -48,22 +46,22 @@ model Canvas { edges Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull) + group Group? @relation(fields: [groupId], references: [id]) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) } model Group { - id String @id @default(cuid()) + id String @id @default(cuid()) name String - description String @default("") - isPublic Boolean @default(true) + description String @default("") + isPublic Boolean @default(true) ownerId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - members GroupMember[] - canvases Canvas[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + controlType String? @default("REMOTE") + canvases Canvas[] + members GroupMember[] + Kiosk Kiosk[] } model GroupMember { @@ -72,9 +70,8 @@ model GroupMember { groupId String role String @default("member") joinedAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, groupId]) } @@ -87,8 +84,7 @@ model Activity { targetType String timestamp DateTime @default(now()) metadata String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) } model Follow { @@ -96,9 +92,19 @@ model Follow { followerId String followingId String since DateTime @default(now()) - - follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) - following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade) + follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade) @@unique([followerId, followingId]) } + +model Kiosk { + id String @id + name String + location String? + groupId String + status String @default("offline") + lastSeen DateTime @default(now()) + createdAt DateTime @default(now()) + Group Group @relation(fields: [groupId], references: [id]) +} diff --git a/scripts/fix_user.cjs b/scripts/fix_user.cjs new file mode 100644 index 0000000..12ad748 --- /dev/null +++ b/scripts/fix_user.cjs @@ -0,0 +1,47 @@ +const { PrismaClient } = require('@prisma/client') +const { PrismaPg } = require('@prisma/adapter-pg') +const { Pool } = require('pg') + +const connectionString = "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db" +const pool = new Pool({ connectionString }) +const adapter = new PrismaPg(pool) +const prisma = new PrismaClient({ adapter }) + +async function main() { + const admins = ["ormix16@gmail.com", "almalexyz@gmail.com"] + + for (const email of admins) { + const username = email.split('@')[0] + console.log(`🔧 Setting prefix as username for: ${email}...`) + + const user = await prisma.user.upsert({ + where: { email }, + update: { + username: username, + role: 'admin' + }, + create: { + id: `admin-init-${Math.random().toString(36).slice(2, 7)}`, + email: email, + username: username, + password: 'managed-auth', + role: 'admin', + profile: { + create: { + displayName: username, + avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}` + } + } + } + }) + + console.log(`✅ ${email} updated. URL will be: /profile/${user.username}`) + } +} + +main() + .catch(console.error) + .finally(async () => { + await prisma.$disconnect() + await pool.end() + })