From e040d8cacd0f1aa90a7c6206d7ab9ae5e029ef32 Mon Sep 17 00:00:00 2001 From: AlexIves16 Date: Sun, 26 Apr 2026 14:37:12 +0500 Subject: [PATCH] feat: migrate Canvas and Groups API to Prisma ORM --- .gitea/workflows/deploy.yml | 4 ++ .obsidian/workspace.json | 2 +- app/server/api/auth/google.post.ts | 61 ++++++++++-------------- app/server/api/canvas/[id].delete.ts | 15 +++--- app/server/api/canvas/[id].get.ts | 57 ++++------------------ app/server/api/canvas/[id].put.ts | 36 +++++++------- app/server/api/canvas/index.get.ts | 57 ++++++++++++---------- app/server/api/canvas/index.post.ts | 36 +++++++------- app/server/api/groups/[id].delete.ts | 9 ++-- app/server/api/groups/[id]/join.post.ts | 15 ++++-- app/server/api/groups/[id]/leave.post.ts | 13 +++-- app/server/api/groups/index.get.ts | 30 ++++++++++-- app/server/api/groups/index.post.ts | 26 +++++----- prisma/schema.prisma | 45 ++--------------- 14 files changed, 183 insertions(+), 223 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 594ec98..273a0b2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -47,5 +47,9 @@ jobs: cp -r .output/. /opt/xflow/.output/ cp -r prisma/. /opt/xflow/prisma/ cp package.json /opt/xflow/package.json + cp prisma.config.ts /opt/xflow/prisma.config.ts docker-compose -f /opt/xflow/docker-compose.yml up -d --force-recreate xflow + echo "⏳ Waiting for container to start..." + sleep 5 + docker exec xflow sh -c "cd /app && npx prisma migrate deploy" echo "✅ XFlow deployed at $(date)" diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index e533145..3e5a02b 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -188,6 +188,7 @@ }, "active": "3f38aefa8626b1f8", "lastOpenFiles": [ + "session-2026-04-23.canvas", "app/server/utils/prisma.ts", "prisma.config.ts", "prisma/schema.prisma", @@ -198,7 +199,6 @@ "node_modules/mysql2/typings/mysql/lib/protocol/packets/params/ErrorPacketParams.d.ts", "node_modules/mysql2/typings/mysql/lib/protocol/sequences/Sequence.d.ts", "node_modules/mysql2/typings/mysql/lib/protocol/sequences/QueryableBase.d.ts", - "node_modules/mysql2/typings/mysql/lib/protocol/sequences/Query.d.ts", "node_modules/mysql2/lib/auth_plugins/caching_sha2_password.md", "node_modules/mysql2/README.md", "node_modules/remeda/README.md", diff --git a/app/server/api/auth/google.post.ts b/app/server/api/auth/google.post.ts index d44c9d5..a92bab3 100644 --- a/app/server/api/auth/google.post.ts +++ b/app/server/api/auth/google.post.ts @@ -1,5 +1,5 @@ import { generateToken } from '~/server/utils/auth' -import { users } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' interface FirebaseUser { localId: string @@ -23,13 +23,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 500, message: 'Firebase API key not configured' }) } - // Verify token via Firebase Auth REST API const verifyRes = await $fetch( `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`, - { - method: 'POST', - body: { idToken } - } + { method: 'POST', body: { idToken } } ).catch(() => null) if (!verifyRes?.users?.[0]) { @@ -37,48 +33,43 @@ export default defineEventHandler(async (event) => { } const fbUser: FirebaseUser = verifyRes.users[0] - const email = fbUser.email - const existing = users.get(email) + const username = fbUser.displayName || email.split('@')[0] + const userId = fbUser.localId - let role = 'user' - let userId = fbUser.localId - - if (existing) { - // Update existing - users.set(email, { - ...existing, + const user = await prisma.user.upsert({ + where: { email }, + update: { username }, + create: { id: userId, email, - username: fbUser.displayName || email.split('@')[0] - }) - role = existing.role || 'user' - } else { - // Create new user - users.set(email, { - id: userId, - email, - username: fbUser.displayName || email.split('@')[0], + username, password: 'firebase-managed', - role: 'user' - }) - } + role: 'user', + profile: { + create: { + displayName: username, + avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}` + } + } + } + }) const token = generateToken({ - id: userId, - email, - username: fbUser.displayName || email.split('@')[0], - role + id: user.id, + email: user.email, + username: user.username, + role: user.role }) return { message: 'Google sign-in successful', token, user: { - id: userId, - email, - username: fbUser.displayName || email.split('@')[0], - role, + id: user.id, + email: user.email, + username: user.username, + role: user.role, photoUrl: fbUser.photoUrl } } diff --git a/app/server/api/canvas/[id].delete.ts b/app/server/api/canvas/[id].delete.ts index 5ddfd8d..8ce4d86 100644 --- a/app/server/api/canvas/[id].delete.ts +++ b/app/server/api/canvas/[id].delete.ts @@ -1,15 +1,16 @@ import { requireAuth } from '~/server/utils/auth' -import { canvases } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { - requireAuth(event) + const user = requireAuth(event) const id = getRouterParam(event, 'id') + if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' }) - if (!canvases.has(id || '')) { - throw createError({ statusCode: 404, message: 'Canvas not found' }) - } + const canvas = await prisma.canvas.findUnique({ where: { id } }) + if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' }) + if (canvas.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' }) - canvases.delete(id || '') + await prisma.canvas.delete({ where: { id } }) return { success: true, message: 'Canvas deleted' } -}) +}) diff --git a/app/server/api/canvas/[id].get.ts b/app/server/api/canvas/[id].get.ts index e08de99..5a9e4ae 100644 --- a/app/server/api/canvas/[id].get.ts +++ b/app/server/api/canvas/[id].get.ts @@ -1,61 +1,24 @@ import { requireAuth } from '~/server/utils/auth' -import { canvases, userGroups } from '~/server/utils/db' +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' }) - 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' }) - const canvas = canvases.get(id) - if (!canvas) { - throw createError({ statusCode: 404, message: 'Canvas not found' }) - } + if (canvas.isPublic) return canvas - // Check if canvas is public - if (canvas.isPublic) { - return { - id: canvas.id, - name: canvas.name, - nodes: canvas.nodes, - edges: canvas.edges, - isPublic: true, - ownerId: canvas.ownerId - } - } - - // For private canvases, require authentication and check permissions const user = requireAuth(event) - // Check if user owns the canvas - if (canvas.ownerId === user.id) { - return { - id: canvas.id, - name: canvas.name, - nodes: canvas.nodes, - edges: canvas.edges, - isPublic: false, - ownerId: canvas.ownerId - } - } + if (canvas.ownerId === user.id) return canvas - // Check if user has access through group membership if (canvas.groupId) { - const membership = Array.from(userGroups.values()).find( - m => m.userId === user.id && m.groupId === canvas.groupId - ) - if (membership) { - return { - id: canvas.id, - name: canvas.name, - nodes: canvas.nodes, - edges: canvas.edges, - isPublic: false, - ownerId: canvas.ownerId, - groupId: canvas.groupId - } - } + const membership = await prisma.groupMember.findUnique({ + where: { userId_groupId: { userId: user.id, groupId: canvas.groupId } } + }) + if (membership) return canvas } throw createError({ statusCode: 403, message: 'Access denied' }) diff --git a/app/server/api/canvas/[id].put.ts b/app/server/api/canvas/[id].put.ts index a785a52..982b7c5 100644 --- a/app/server/api/canvas/[id].put.ts +++ b/app/server/api/canvas/[id].put.ts @@ -1,24 +1,28 @@ import { requireAuth } from '~/server/utils/auth' -import { canvases } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { - requireAuth(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 = canvases.get(id || '') - if (!existing) { - throw createError({ statusCode: 404, message: 'Canvas not found' }) - } + const existing = await prisma.canvas.findUnique({ where: { id } }) + if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' }) + if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' }) - const updated = { - ...existing, - ...body, - id: existing.id, - updatedAt: new Date().toISOString() - } + 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 }) + } + }) - canvases.set(id || '', updated) - - return { canvas: updated } -}) + return { canvas } +}) diff --git a/app/server/api/canvas/index.get.ts b/app/server/api/canvas/index.get.ts index bcffcd6..8cb607c 100644 --- a/app/server/api/canvas/index.get.ts +++ b/app/server/api/canvas/index.get.ts @@ -1,34 +1,41 @@ import { requireAuth } from '~/server/utils/auth' -import { canvases, userGroups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { const user = requireAuth(event) - // Get all canvases the user can access - const userGroupIds = Array.from(userGroups.values()) - .filter(membership => membership.userId === user.id) - .map(membership => membership.groupId) + const userGroups = await prisma.groupMember.findMany({ + where: { userId: user.id }, + select: { groupId: true } + }) + const groupIds = userGroups.map(m => m.groupId) - const accessibleCanvases = Array.from(canvases.values()).filter(canvas => { - // User can see their own canvases - if (canvas.ownerId === user.id) return true - // User can see public canvases - if (canvas.isPublic) return true - // User can see group canvases they're a member of - if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true - return false + 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 canvasList = accessibleCanvases.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 } + return { + canvases: canvasList.map(c => ({ + ...c, + nodeCount: Array.isArray(c.nodes) ? (c.nodes as any[]).length : 0, + nodes: undefined + })) + } }) diff --git a/app/server/api/canvas/index.post.ts b/app/server/api/canvas/index.post.ts index 6bad82d..957a30f 100644 --- a/app/server/api/canvas/index.post.ts +++ b/app/server/api/canvas/index.post.ts @@ -1,36 +1,32 @@ import { requireAuth } from '~/server/utils/auth' -import { canvases, userGroups } from '~/server/utils/db' -import type { CanvasData } from '~/server/utils/db' +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 - // Check group permissions if groupId is provided if (groupId) { - const membership = Array.from(userGroups.values()).find( - m => m.userId === user.id && m.groupId === 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: CanvasData = { - id, - name, - nodes, - edges, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ownerId: user.id, - groupId, - isPublic - } - - canvases.set(id, canvas) + const canvas = await prisma.canvas.create({ + data: { + id, + name, + nodes, + edges, + isPublic, + ownerId: user.id, + groupId: groupId || null + } + }) return { canvas } -}) +}) diff --git a/app/server/api/groups/[id].delete.ts b/app/server/api/groups/[id].delete.ts index 343159d..25a7901 100644 --- a/app/server/api/groups/[id].delete.ts +++ b/app/server/api/groups/[id].delete.ts @@ -1,20 +1,17 @@ import { requireAuth } from '~/server/utils/auth' -import { groups, userGroups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { const user = requireAuth(event) const groupId = getRouterParam(event, 'id')! - const group = groups.get(groupId) + 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' }) } - groups.delete(groupId) - for (const [key] of userGroups) { - if (key.endsWith(`-${groupId}`)) userGroups.delete(key) - } + await prisma.group.delete({ where: { id: groupId } }) return { success: true } }) diff --git a/app/server/api/groups/[id]/join.post.ts b/app/server/api/groups/[id]/join.post.ts index e1901ef..6326fca 100644 --- a/app/server/api/groups/[id]/join.post.ts +++ b/app/server/api/groups/[id]/join.post.ts @@ -1,17 +1,22 @@ import { requireAuth } from '~/server/utils/auth' -import { groups, userGroups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { const user = requireAuth(event) const groupId = getRouterParam(event, 'id')! - const group = groups.get(groupId) + 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 key = `${user.id}-${groupId}` - if (userGroups.has(key)) return { message: 'Already a member' } + 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' } + }) - userGroups.set(key, { userId: user.id, groupId, role: 'member', joinedAt: new Date().toISOString() }) return { success: true, message: `Joined group "${group.name}"` } }) diff --git a/app/server/api/groups/[id]/leave.post.ts b/app/server/api/groups/[id]/leave.post.ts index 1dab513..8e28b39 100644 --- a/app/server/api/groups/[id]/leave.post.ts +++ b/app/server/api/groups/[id]/leave.post.ts @@ -1,17 +1,20 @@ import { requireAuth } from '~/server/utils/auth' -import { groups, userGroups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { const user = requireAuth(event) const groupId = getRouterParam(event, 'id')! - const group = groups.get(groupId) + 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 key = `${user.id}-${groupId}` - if (!userGroups.has(key)) throw createError({ statusCode: 400, message: 'Not a member' }) + 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 } }) - userGroups.delete(key) return { success: true, message: `Left group "${group.name}"` } }) diff --git a/app/server/api/groups/index.get.ts b/app/server/api/groups/index.get.ts index bbb00ce..a73bbd2 100644 --- a/app/server/api/groups/index.get.ts +++ b/app/server/api/groups/index.get.ts @@ -1,8 +1,30 @@ import { requireAuth } from '~/server/utils/auth' -import { groups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { - requireAuth(event) - const allGroups = Array.from(groups.values()).filter(g => g.isPublic) - return { groups: allGroups } + const user = requireAuth(event) + + const allGroups = await prisma.group.findMany({ + where: { 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) + })) + } }) diff --git a/app/server/api/groups/index.post.ts b/app/server/api/groups/index.post.ts index a90a64e..40a0c68 100644 --- a/app/server/api/groups/index.post.ts +++ b/app/server/api/groups/index.post.ts @@ -1,5 +1,5 @@ import { requireAuth } from '~/server/utils/auth' -import { groups, userGroups } from '~/server/utils/db' +import { prisma } from '~/server/utils/prisma' export default defineEventHandler(async (event) => { const user = requireAuth(event) @@ -10,17 +10,19 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, message: 'Group name is required' }) } - const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` - const now = new Date().toISOString() - - const group = { id, name: name.trim(), description, isPublic, createdAt: now, updatedAt: now, ownerId: user.id } - groups.set(id, group) - - userGroups.set(`${user.id}-${id}`, { - userId: user.id, - groupId: id, - role: 'admin', - joinedAt: now + const group = await prisma.group.create({ + data: { + name: name.trim(), + description, + isPublic, + ownerId: user.id, + members: { + create: { + userId: user.id, + role: 'admin' + } + } + } }) return { group } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22f87fb..66660a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,6 +5,7 @@ generator client { datasource db { provider = "postgresql" + url = env("DATABASE_URL") } model User { @@ -44,49 +45,13 @@ model Canvas { ownerId String groupId String? isPublic Boolean @default(false) + nodes Json @default("[]") + edges Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - nodes CanvasNode[] - edges CanvasEdge[] - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull) -} - -model CanvasNode { - id String @id - canvasId String - type String - x Float - y Float - width Float - height Float - text String? - file String? - state String? - label String? - assignedTo String? - alarm String? - scriptId String? - prompt String? - url String? - method String? - repo String? - branch String? - styleJson String? - - canvas Canvas @relation(fields: [canvasId], references: [id], onDelete: Cascade) -} - -model CanvasEdge { - id String @id - canvasId String - fromNode String - fromSide String - toNode String - toSide String - - canvas Canvas @relation(fields: [canvasId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull) } model Group {