Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3dff3d4
feat: add workspace management functionality
MathisVerstrepen Jan 2, 2026
5f646a5
fix: consolidate type imports in actions.vue
MathisVerstrepen Jan 2, 2026
3eacec6
feat: enhance delete_workspace function to include user authorization…
MathisVerstrepen Jan 2, 2026
1a16cd4
feat: add state persistance of current workspace on page reload
MathisVerstrepen Jan 3, 2026
45159b2
feat: implement workspace switching functionality with throttle and w…
MathisVerstrepen Jan 3, 2026
92e7909
feat: refactor sidebar components and implement workspace management …
MathisVerstrepen Jan 3, 2026
31e680a
feat: update sidebar action labels and enhance submenu styling
MathisVerstrepen Jan 3, 2026
020245f
feat: create default 'Home' workspace when a new user is added
MathisVerstrepen Jan 3, 2026
dd4ebb5
feat: rename default workspace from 'Home' to 'Default' for new users
MathisVerstrepen Jan 3, 2026
d1d53e2
feat: set default workspace for new graphs if not specified
MathisVerstrepen Jan 3, 2026
e00cf0f
feat: display message when no items are available in submenu
MathisVerstrepen Jan 3, 2026
e9333c2
feat: enhance sidebar history overflow handling with mutation observer
MathisVerstrepen Jan 3, 2026
3fa26c6
feat: refactor sidebar history component for improved structure and s…
MathisVerstrepen Jan 3, 2026
9caaa7d
feat: update getOrganizedData to accept graphs and folders as paramet…
MathisVerstrepen Jan 3, 2026
7552d6a
feat: add functionality to move folders between workspaces and update…
MathisVerstrepen Jan 3, 2026
9bf2240
feat: sort folders and workspaces alphabetically in move items list
MathisVerstrepen Jan 3, 2026
0deeea0
feat: add search scope functionality to sidebar history for improved …
MathisVerstrepen Jan 3, 2026
fc08e51
feat: add workspace support to recent canvas section in home page
MathisVerstrepen Jan 3, 2026
f492344
feat: implement unified search bar component with workspace and globa…
MathisVerstrepen Jan 3, 2026
482784e
feat: enhance workspace switching functionality with wheel event support
MathisVerstrepen Jan 3, 2026
7a37023
feat: update workspace visibility logic in recent canvas section
MathisVerstrepen Jan 3, 2026
fe72af7
feat: enhance update workspace function to include user ID for better…
MathisVerstrepen Jan 3, 2026
eac9de5
fix: linter
MathisVerstrepen Jan 3, 2026
2ddeaaa
feat: add UUID extension creation in workspace migration
MathisVerstrepen Jan 3, 2026
c3c7553
fix: update wheel event listener to be passive for improved performance
MathisVerstrepen Jan 3, 2026
8763bea
feat: enhance graph movement logic to support moving to workspace roo…
MathisVerstrepen Jan 3, 2026
a68a945
fix: add focus-visible outline to workspace buttons for better access…
MathisVerstrepen Jan 3, 2026
a8fb815
fix: filter valid folders before displaying in move items
MathisVerstrepen Jan 3, 2026
0d8492a
feat: extend useSidebarWorkspaces to include folders for improved wor…
MathisVerstrepen Jan 3, 2026
86d22cb
fix: remove order_index from default workspace creation for user
MathisVerstrepen Jan 3, 2026
ed7df09
Merge pull request #266 from MathisVerstrepen/dev
MathisVerstrepen Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 207 additions & 6 deletions api/app/database/pg/graph_ops/graph_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
from datetime import datetime, timedelta, timezone

from database.neo4j.crud import update_neo4j_graph
from database.pg.models import Edge, Folder, Graph, Node
from database.pg.models import Edge, Folder, Graph, Node, Workspace
from fastapi import HTTPException
from models.usersDTO import SettingsDTO
from neo4j import AsyncDriver
from neo4j.exceptions import Neo4jError
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncEngine as SQLAlchemyAsyncEngine
from sqlalchemy.orm import selectinload
from sqlmodel import and_
Expand Down Expand Up @@ -68,10 +68,16 @@ async def get_user_folders(engine: SQLAlchemyAsyncEngine, user_id: str) -> list[
return list(result.scalars().all())


async def create_folder(engine: SQLAlchemyAsyncEngine, user_id: str, name: str) -> Folder:
async def create_folder(
engine: SQLAlchemyAsyncEngine, user_id: str, name: str, workspace_id: str | None = None
) -> Folder:
"""Create a new folder."""
async with AsyncSession(engine) as session:
folder = Folder(name=name, user_id=user_id)
folder = Folder(
name=name,
user_id=user_id,
workspace_id=uuid.UUID(workspace_id) if workspace_id else None,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
Expand Down Expand Up @@ -106,6 +112,35 @@ async def update_folder_color(engine: SQLAlchemyAsyncEngine, folder_id: str, col
return folder


async def update_folder_workspace(
engine: SQLAlchemyAsyncEngine, folder_id: str, workspace_id: str
) -> Folder:
"""
Move a folder to a different workspace.
This also updates all graphs contained in this folder to the new workspace.
"""
async with AsyncSession(engine) as session:
async with session.begin():
folder = await session.get(Folder, folder_id)
if not folder:
raise HTTPException(status_code=404, detail="Folder not found")

new_ws_uuid = uuid.UUID(workspace_id)
folder.workspace_id = new_ws_uuid
session.add(folder)

# Update contained graphs
stmt = (
update(Graph)
.where(and_(Graph.folder_id == folder_id))
.values(workspace_id=new_ws_uuid)
)
await session.exec(stmt) # type: ignore

await session.refresh(folder)
return folder


async def delete_folder(engine: SQLAlchemyAsyncEngine, folder_id: str) -> None:
"""Delete a folder. Graphs inside will have folder_id set to NULL due to ON DELETE SET NULL."""
async with AsyncSession(engine) as session:
Expand All @@ -119,7 +154,10 @@ async def delete_folder(engine: SQLAlchemyAsyncEngine, folder_id: str) -> None:
async def move_graph_to_folder(
engine: SQLAlchemyAsyncEngine, graph_id: str, folder_id: str | None
) -> Graph:
"""Move a graph to a specific folder (or root if folder_id is None)."""
"""
Move a graph to a specific folder (or root if folder_id is None).
If moved to a folder, it adopts the folder's workspace.
"""
async with AsyncSession(engine) as session:
async with session.begin():
stmt = select(Graph).where(and_(Graph.id == graph_id))
Expand All @@ -130,6 +168,13 @@ async def move_graph_to_folder(
raise HTTPException(status_code=404, detail="Graph not found")

graph.folder_id = uuid.UUID(folder_id) if folder_id else None

# If moving to a folder, update workspace_id to match the folder's workspace
if folder_id:
folder = await session.get(Folder, folder_id)
if folder:
graph.workspace_id = folder.workspace_id

session.add(graph)

await session.refresh(graph, attribute_names=["folder_id", "nodes"])
Expand All @@ -143,6 +188,32 @@ async def move_graph_to_folder(
return graph


async def move_graph_to_workspace(
engine: SQLAlchemyAsyncEngine, graph_id: str, workspace_id: str
) -> Graph:
"""Move a graph to a specific workspace. Removes it from any folder."""
async with AsyncSession(engine) as session:
async with session.begin():
graph = await session.get(Graph, graph_id)
if not graph:
raise HTTPException(status_code=404, detail="Graph not found")

graph.workspace_id = uuid.UUID(workspace_id)
graph.folder_id = None
session.add(graph)

await session.refresh(graph, attribute_names=["workspace_id", "folder_id", "nodes"])

if not isinstance(graph, Graph):
raise HTTPException(
status_code=500, detail="Unexpected error: Retrieved object is not of type Graph."
)

graph.node_count = len(graph.nodes)

return graph


class CompleteGraph(BaseModel):
graph: Graph
nodes: list[Node]
Expand Down Expand Up @@ -183,7 +254,11 @@ async def get_graph_by_id(engine: SQLAlchemyAsyncEngine, graph_id: str) -> Compl


async def create_empty_graph(
engine: SQLAlchemyAsyncEngine, user_id: str, user_config: SettingsDTO, temporary: bool
engine: SQLAlchemyAsyncEngine,
user_id: str,
user_config: SettingsDTO,
temporary: bool,
workspace_id: str | None = None,
) -> Graph:
"""
Create an empty graph in the database.
Expand All @@ -199,10 +274,26 @@ async def create_empty_graph(

async with AsyncSession(engine) as session:
async with session.begin():
target_workspace_id = None
if workspace_id:
target_workspace_id = uuid.UUID(workspace_id)
else:
stmt = (
select(Workspace)
.where(and_(Workspace.user_id == user_id))
.order_by(Workspace.created_at) # type: ignore
.limit(1)
)
result = await session.exec(stmt) # type: ignore
default_ws = result.scalars().first()
if default_ws:
target_workspace_id = default_ws.id

graph = Graph(
name="New Canvas",
user_id=user_id,
temporary=temporary,
workspace_id=target_workspace_id,
custom_instructions=systemPromptSelected,
max_tokens=user_config.models.maxTokens,
temperature=user_config.models.temperature,
Expand Down Expand Up @@ -356,3 +447,113 @@ async def delete_old_temporary_graphs(
f"Cron job: Successfully deleted {len(graph_ids_to_delete)} "
f"temporary graphs from PostgreSQL."
)


async def get_user_workspaces(engine: SQLAlchemyAsyncEngine, user_id: str) -> list[Workspace]:
"""Retrieve all workspaces for a user. Create default if none exist."""
async with AsyncSession(engine) as session:
stmt = (
select(Workspace)
.where(and_(Workspace.user_id == user_id))
.order_by(Workspace.created_at) # type: ignore
)
result = await session.exec(stmt) # type: ignore
workspaces = list(result.scalars().all())

return workspaces


async def create_workspace(engine: SQLAlchemyAsyncEngine, user_id: str, name: str) -> Workspace:
async with AsyncSession(engine) as session:
ws = Workspace(name=name, user_id=user_id)
session.add(ws)
await session.commit()
await session.refresh(ws)
return ws


async def update_workspace(
engine: SQLAlchemyAsyncEngine,
workspace_id: str,
user_id: str,
name: str,
) -> Workspace:
async with AsyncSession(engine) as session:
stmt = select(Workspace).where(
and_(Workspace.id == workspace_id, Workspace.user_id == user_id)
)
result = await session.exec(stmt) # type: ignore
ws = result.scalar_one_or_none()

if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")

ws.name = name
session.add(ws)
await session.commit()
await session.refresh(ws)

if not isinstance(ws, Workspace):
raise HTTPException(
status_code=500,
detail="Unexpected error: Retrieved object is not of type Workspace.",
)

return ws


async def delete_workspace(engine: SQLAlchemyAsyncEngine, workspace_id: str, user_id: str) -> None:
async with AsyncSession(engine) as session:
async with session.begin():
ws = await session.get(Workspace, workspace_id)
if not ws:
raise HTTPException(status_code=404, detail="Workspace not found")

if str(ws.user_id) != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to delete this workspace"
)

# Prevent deleting the last workspace
stmt_count = (
select(func.count())
.select_from(Workspace)
.where(and_(Workspace.user_id == user_id))
)
count = await session.scalar(stmt_count)
if count is None or count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the only workspace.")

# Identify Fallback Workspace (Oldest remaining that is not the target)
stmt_fallback = (
select(Workspace)
.where(and_(Workspace.user_id == user_id, Workspace.id != workspace_id))
.order_by(Workspace.created_at) # type: ignore
.limit(1)
)
result_fallback = await session.exec(stmt_fallback) # type: ignore
fallback_ws = result_fallback.scalar_one_or_none()

if not fallback_ws:
raise HTTPException(
status_code=500, detail="Unable to identify fallback workspace."
)

# Migrate Folders to fallback workspace
stmt_folders = (
update(Folder)
.where(and_(Folder.workspace_id == workspace_id))
.values(workspace_id=fallback_ws.id)
)
await session.exec(stmt_folders) # type: ignore

# Migrate Graphs to fallback workspace
stmt_graphs = (
update(Graph)
.where(and_(Graph.workspace_id == workspace_id))
.values(workspace_id=fallback_ws.id)
)
await session.exec(stmt_graphs) # type: ignore

# Delete the workspace
await session.delete(ws)
60 changes: 60 additions & 0 deletions api/app/database/pg/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,48 @@ class UserStorageUsage(SQLModel, table=True):
)


class Workspace(SQLModel, table=True):
__tablename__ = "workspaces"

id: Optional[uuid.UUID] = Field(
default=None,
sa_column=Column(
PG_UUID(as_uuid=True),
primary_key=True,
server_default=func.uuid_generate_v4(),
nullable=False,
),
)
user_id: uuid.UUID = Field(
sa_column=Column(
PG_UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
)
name: str = Field(max_length=50, nullable=False)
created_at: Optional[datetime.datetime] = Field(
default=None,
sa_column=Column(
TIMESTAMP(timezone=True),
server_default=func.now(),
nullable=False,
),
)
updated_at: Optional[datetime.datetime] = Field(
default=None,
sa_column=Column(
TIMESTAMP(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
),
)

graphs: list["Graph"] = Relationship(back_populates="workspace")
folders: list["Folder"] = Relationship(back_populates="workspace")


class Folder(SQLModel, table=True):
__tablename__ = "folders"

Expand All @@ -71,6 +113,14 @@ class Folder(SQLModel, table=True):
nullable=False,
),
)
workspace_id: Optional[uuid.UUID] = Field(
default=None,
sa_column=Column(
PG_UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True,
),
)
name: str = Field(max_length=255, nullable=False)
color: Optional[str] = Field(default=None, max_length=50, nullable=True)

Expand All @@ -93,6 +143,7 @@ class Folder(SQLModel, table=True):
)

graphs: list["Graph"] = Relationship(back_populates="folder")
workspace: Optional[Workspace] = Relationship(back_populates="folders")


class Graph(SQLModel, table=True):
Expand Down Expand Up @@ -123,6 +174,14 @@ class Graph(SQLModel, table=True):
nullable=True,
),
)
workspace_id: Optional[uuid.UUID] = Field(
default=None,
sa_column=Column(
PG_UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
),
)

name: str = Field(index=True, max_length=255, nullable=False)
description: Optional[str] = Field(default=None, sa_column=Column(TEXT)) # not used
Expand Down Expand Up @@ -178,6 +237,7 @@ class Graph(SQLModel, table=True):
edges: list["Edge"] = Relationship(back_populates="graph")

folder: Optional[Folder] = Relationship(back_populates="graphs")
workspace: Optional[Workspace] = Relationship(back_populates="graphs")

_node_count: Optional[int] = PrivateAttr(default=None)

Expand Down
Loading
Loading