feat: migrate Canvas and Groups API to Prisma ORM

This commit is contained in:
2026-04-26 14:37:12 +05:00
parent f9e35cb9e7
commit e040d8cacd
14 changed files with 183 additions and 223 deletions
+4
View File
@@ -47,5 +47,9 @@ jobs:
cp -r .output/. /opt/xflow/.output/ cp -r .output/. /opt/xflow/.output/
cp -r prisma/. /opt/xflow/prisma/ cp -r prisma/. /opt/xflow/prisma/
cp package.json /opt/xflow/package.json 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 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)" echo "✅ XFlow deployed at $(date)"
+1 -1
View File
@@ -188,6 +188,7 @@
}, },
"active": "3f38aefa8626b1f8", "active": "3f38aefa8626b1f8",
"lastOpenFiles": [ "lastOpenFiles": [
"session-2026-04-23.canvas",
"app/server/utils/prisma.ts", "app/server/utils/prisma.ts",
"prisma.config.ts", "prisma.config.ts",
"prisma/schema.prisma", "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/packets/params/ErrorPacketParams.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/Sequence.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/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/lib/auth_plugins/caching_sha2_password.md",
"node_modules/mysql2/README.md", "node_modules/mysql2/README.md",
"node_modules/remeda/README.md", "node_modules/remeda/README.md",
+26 -35
View File
@@ -1,5 +1,5 @@
import { generateToken } from '~/server/utils/auth' import { generateToken } from '~/server/utils/auth'
import { users } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
interface FirebaseUser { interface FirebaseUser {
localId: string localId: string
@@ -23,13 +23,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 500, message: 'Firebase API key not configured' }) throw createError({ statusCode: 500, message: 'Firebase API key not configured' })
} }
// Verify token via Firebase Auth REST API
const verifyRes = await $fetch<any>( const verifyRes = await $fetch<any>(
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`, `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`,
{ { method: 'POST', body: { idToken } }
method: 'POST',
body: { idToken }
}
).catch(() => null) ).catch(() => null)
if (!verifyRes?.users?.[0]) { if (!verifyRes?.users?.[0]) {
@@ -37,48 +33,43 @@ export default defineEventHandler(async (event) => {
} }
const fbUser: FirebaseUser = verifyRes.users[0] const fbUser: FirebaseUser = verifyRes.users[0]
const email = fbUser.email const email = fbUser.email
const existing = users.get(email) const username = fbUser.displayName || email.split('@')[0]
const userId = fbUser.localId
let role = 'user' const user = await prisma.user.upsert({
let userId = fbUser.localId where: { email },
update: { username },
if (existing) { create: {
// Update existing
users.set(email, {
...existing,
id: userId, id: userId,
email, email,
username: fbUser.displayName || email.split('@')[0] username,
})
role = existing.role || 'user'
} else {
// Create new user
users.set(email, {
id: userId,
email,
username: fbUser.displayName || email.split('@')[0],
password: 'firebase-managed', 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({ const token = generateToken({
id: userId, id: user.id,
email, email: user.email,
username: fbUser.displayName || email.split('@')[0], username: user.username,
role role: user.role
}) })
return { return {
message: 'Google sign-in successful', message: 'Google sign-in successful',
token, token,
user: { user: {
id: userId, id: user.id,
email, email: user.email,
username: fbUser.displayName || email.split('@')[0], username: user.username,
role, role: user.role,
photoUrl: fbUser.photoUrl photoUrl: fbUser.photoUrl
} }
} }
+7 -6
View File
@@ -1,15 +1,16 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) const user = requireAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
if (!canvases.has(id || '')) { const canvas = await prisma.canvas.findUnique({ where: { id } })
throw createError({ statusCode: 404, message: 'Canvas not found' }) 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' } return { success: true, message: 'Canvas deleted' }
}) })
+10 -47
View File
@@ -1,61 +1,24 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
if (!id) { const canvas = await prisma.canvas.findUnique({ where: { id } })
throw createError({ statusCode: 400, message: 'Canvas ID is required' }) if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' })
}
const canvas = canvases.get(id) if (canvas.isPublic) return canvas
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// 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) const user = requireAuth(event)
// Check if user owns the canvas if (canvas.ownerId === user.id) return canvas
if (canvas.ownerId === user.id) {
return {
id: canvas.id,
name: canvas.name,
nodes: canvas.nodes,
edges: canvas.edges,
isPublic: false,
ownerId: canvas.ownerId
}
}
// Check if user has access through group membership
if (canvas.groupId) { if (canvas.groupId) {
const membership = Array.from(userGroups.values()).find( const membership = await prisma.groupMember.findUnique({
m => m.userId === user.id && m.groupId === canvas.groupId where: { userId_groupId: { userId: user.id, groupId: canvas.groupId } }
) })
if (membership) { if (membership) return canvas
return {
id: canvas.id,
name: canvas.name,
nodes: canvas.nodes,
edges: canvas.edges,
isPublic: false,
ownerId: canvas.ownerId,
groupId: canvas.groupId
}
}
} }
throw createError({ statusCode: 403, message: 'Access denied' }) throw createError({ statusCode: 403, message: 'Access denied' })
+19 -15
View File
@@ -1,24 +1,28 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) const user = requireAuth(event)
const id = getRouterParam(event, 'id') const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
const body = await readBody(event) const body = await readBody(event)
const existing = canvases.get(id || '') const existing = await prisma.canvas.findUnique({ where: { id } })
if (!existing) { if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
throw createError({ statusCode: 404, message: 'Canvas not found' }) if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' })
}
const updated = { const { name, nodes, edges, isPublic, groupId } = body
...existing, const canvas = await prisma.canvas.update({
...body, where: { id },
id: existing.id, data: {
updatedAt: new Date().toISOString() ...(name !== undefined && { name }),
} ...(nodes !== undefined && { nodes }),
...(edges !== undefined && { edges }),
...(isPublic !== undefined && { isPublic }),
...(groupId !== undefined && { groupId: groupId || null })
}
})
canvases.set(id || '', updated) return { canvas }
return { canvas: updated }
}) })
+32 -25
View File
@@ -1,34 +1,41 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
// Get all canvases the user can access const userGroups = await prisma.groupMember.findMany({
const userGroupIds = Array.from(userGroups.values()) where: { userId: user.id },
.filter(membership => membership.userId === user.id) select: { groupId: true }
.map(membership => membership.groupId) })
const groupIds = userGroups.map(m => m.groupId)
const accessibleCanvases = Array.from(canvases.values()).filter(canvas => { const canvasList = await prisma.canvas.findMany({
// User can see their own canvases where: {
if (canvas.ownerId === user.id) return true OR: [
// User can see public canvases { ownerId: user.id },
if (canvas.isPublic) return true { isPublic: true },
// User can see group canvases they're a member of { groupId: { in: groupIds } }
if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true ]
return false },
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 => ({ return {
id: canvas.id, canvases: canvasList.map(c => ({
name: canvas.name, ...c,
ownerId: canvas.ownerId, nodeCount: Array.isArray(c.nodes) ? (c.nodes as any[]).length : 0,
createdAt: canvas.createdAt, nodes: undefined
updatedAt: canvas.updatedAt, }))
isPublic: canvas.isPublic, }
groupId: canvas.groupId,
nodeCount: canvas.nodes.length
}))
return { canvases: canvasList }
}) })
+15 -19
View File
@@ -1,36 +1,32 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
import type { CanvasData } from '~/server/utils/db'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const body = await readBody(event) const body = await readBody(event)
const { name = 'New Canvas', nodes = [], edges = [], isPublic = false, groupId } = body const { name = 'New Canvas', nodes = [], edges = [], isPublic = false, groupId } = body
// Check group permissions if groupId is provided
if (groupId) { if (groupId) {
const membership = Array.from(userGroups.values()).find( const membership = await prisma.groupMember.findUnique({
m => m.userId === user.id && m.groupId === groupId where: { userId_groupId: { userId: user.id, groupId } }
) })
if (!membership) { if (!membership) {
throw createError({ statusCode: 403, message: 'Not a member of this group' }) throw createError({ statusCode: 403, message: 'Not a member of this group' })
} }
} }
const id = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const id = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const canvas: CanvasData = { const canvas = await prisma.canvas.create({
id, data: {
name, id,
nodes, name,
edges, nodes,
createdAt: new Date().toISOString(), edges,
updatedAt: new Date().toISOString(), isPublic,
ownerId: user.id, ownerId: user.id,
groupId, groupId: groupId || null
isPublic }
} })
canvases.set(id, canvas)
return { canvas } return { canvas }
}) })
+3 -6
View File
@@ -1,20 +1,17 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')! 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) throw createError({ statusCode: 404, message: 'Group not found' })
if (group.ownerId !== user.id && user.role !== 'admin') { if (group.ownerId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Only owner can delete group' }) throw createError({ statusCode: 403, message: 'Only owner can delete group' })
} }
groups.delete(groupId) await prisma.group.delete({ where: { id: groupId } })
for (const [key] of userGroups) {
if (key.endsWith(`-${groupId}`)) userGroups.delete(key)
}
return { success: true } return { success: true }
}) })
+10 -5
View File
@@ -1,17 +1,22 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')! 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) throw createError({ statusCode: 404, message: 'Group not found' })
if (!group.isPublic) throw createError({ statusCode: 403, message: 'Group is private' }) if (!group.isPublic) throw createError({ statusCode: 403, message: 'Group is private' })
const key = `${user.id}-${groupId}` const existing = await prisma.groupMember.findUnique({
if (userGroups.has(key)) return { message: 'Already a member' } 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}"` } return { success: true, message: `Joined group "${group.name}"` }
}) })
+8 -5
View File
@@ -1,17 +1,20 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')! 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) 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.' }) if (group.ownerId === user.id) throw createError({ statusCode: 400, message: 'Owner cannot leave group. Delete it instead.' })
const key = `${user.id}-${groupId}` const membership = await prisma.groupMember.findUnique({
if (!userGroups.has(key)) throw createError({ statusCode: 400, message: 'Not a member' }) 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}"` } return { success: true, message: `Left group "${group.name}"` }
}) })
+26 -4
View File
@@ -1,8 +1,30 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { groups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
requireAuth(event) const user = requireAuth(event)
const allGroups = Array.from(groups.values()).filter(g => g.isPublic)
return { groups: allGroups } 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)
}))
}
}) })
+14 -12
View File
@@ -1,5 +1,5 @@
import { requireAuth } from '~/server/utils/auth' import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db' import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
@@ -10,17 +10,19 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'Group name is required' }) throw createError({ statusCode: 400, message: 'Group name is required' })
} }
const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` const group = await prisma.group.create({
const now = new Date().toISOString() data: {
name: name.trim(),
const group = { id, name: name.trim(), description, isPublic, createdAt: now, updatedAt: now, ownerId: user.id } description,
groups.set(id, group) isPublic,
ownerId: user.id,
userGroups.set(`${user.id}-${id}`, { members: {
userId: user.id, create: {
groupId: id, userId: user.id,
role: 'admin', role: 'admin'
joinedAt: now }
}
}
}) })
return { group } return { group }
+5 -40
View File
@@ -5,6 +5,7 @@ generator client {
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
} }
model User { model User {
@@ -44,49 +45,13 @@ model Canvas {
ownerId String ownerId String
groupId String? groupId String?
isPublic Boolean @default(false) isPublic Boolean @default(false)
nodes Json @default("[]")
edges Json @default("[]")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
nodes CanvasNode[] owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
edges CanvasEdge[] group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull)
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)
} }
model Group { model Group {