From 3dff3d43b5cfb2bf1b45e3a3d6c5cd15dd0c17ec Mon Sep 17 00:00:00 2001 From: Mathis Verstrepen Date: Fri, 2 Jan 2026 18:41:38 -0500 Subject: [PATCH 01/30] feat: add workspace management functionality --- api/app/database/pg/graph_ops/graph_crud.py | 103 ++++++++- api/app/database/pg/models.py | 60 +++++ api/app/routers/graph.py | 57 ++++- .../fd447b227777_add_workspace_table.py | 114 ++++++++++ ui/app/assets/icons/MdiBriefcaseOutline.svg | 1 + .../assets/icons/MdiFolderRemoveOutline.svg | 1 + .../components/ui/sidebar-history/actions.vue | 61 +++-- .../ui/sidebar-history/sidebarHistory.vue | 215 ++++++++++++++++-- .../sidebar-history/sidebarHistoryFolder.vue | 11 +- .../ui/sidebar-history/sidebarHistoryItem.vue | 14 +- .../sidebarHistoryWorkspacePagination.vue | 37 +++ .../components/ui/sidebar-history/submenu.vue | 67 +++--- ui/app/composables/useAPI.ts | 57 ++++- ui/app/types/graph.d.ts | 9 + 14 files changed, 727 insertions(+), 80 deletions(-) create mode 100644 api/migrations/versions/fd447b227777_add_workspace_table.py create mode 100644 ui/app/assets/icons/MdiBriefcaseOutline.svg create mode 100644 ui/app/assets/icons/MdiFolderRemoveOutline.svg create mode 100644 ui/app/components/ui/sidebar-history/sidebarHistoryWorkspacePagination.vue diff --git a/api/app/database/pg/graph_ops/graph_crud.py b/api/app/database/pg/graph_ops/graph_crud.py index df566f46..4319c493 100644 --- a/api/app/database/pg/graph_ops/graph_crud.py +++ b/api/app/database/pg/graph_ops/graph_crud.py @@ -3,7 +3,7 @@ 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 @@ -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) @@ -119,7 +125,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)) @@ -130,6 +139,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"]) @@ -143,6 +159,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] @@ -183,7 +225,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. @@ -203,6 +249,7 @@ async def create_empty_graph( name="New Canvas", user_id=user_id, temporary=temporary, + workspace_id=uuid.UUID(workspace_id) if workspace_id else None, custom_instructions=systemPromptSelected, max_tokens=user_config.models.maxTokens, temperature=user_config.models.temperature, @@ -356,3 +403,49 @@ 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, name: str +) -> Workspace: + async with AsyncSession(engine) as session: + ws = await session.get(Workspace, workspace_id) + 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) + return ws + + +async def delete_workspace(engine: SQLAlchemyAsyncEngine, workspace_id: str) -> None: + async with AsyncSession(engine) as session: + ws = await session.get(Workspace, workspace_id) + if not ws: + raise HTTPException(status_code=404, detail="Workspace not found") + await session.delete(ws) + await session.commit() diff --git a/api/app/database/pg/models.py b/api/app/database/pg/models.py index 3dc111c6..fd0f12ca 100644 --- a/api/app/database/pg/models.py +++ b/api/app/database/pg/models.py @@ -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" @@ -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) @@ -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): @@ -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 @@ -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) diff --git a/api/app/routers/graph.py b/api/app/routers/graph.py index 0b384b39..b6440df1 100644 --- a/api/app/routers/graph.py +++ b/api/app/routers/graph.py @@ -8,18 +8,23 @@ CompleteGraph, create_empty_graph, create_folder, + create_workspace, delete_folder, delete_graph, + delete_workspace, get_all_graphs, get_graph_by_id, get_user_folders, + get_user_workspaces, move_graph_to_folder, + move_graph_to_workspace, persist_temporary_graph, update_folder_color, update_folder_name, + update_workspace, ) from database.pg.graph_ops.graph_node_crud import update_graph_with_nodes_and_edges -from database.pg.models import Folder, Graph +from database.pg.models import Folder, Graph, Workspace from database.pg.user_ops.usage_limits import check_free_tier_canvas_limit, validate_premium_nodes from fastapi import APIRouter, Depends, HTTPException, Request from models.graphDTO import NodeSearchRequest @@ -73,6 +78,7 @@ async def route_get_graph_by_id( async def route_create_new_empty_graph( request: Request, temporary: bool = False, + workspace_id: str | None = None, user_id: str = Depends(get_current_user_id), ) -> Graph: """ @@ -89,7 +95,9 @@ async def route_create_new_empty_graph( user_settings = await get_user_settings(request.app.state.pg_engine, user_id) - return await create_empty_graph(request.app.state.pg_engine, user_id, user_settings, temporary) + return await create_empty_graph( + request.app.state.pg_engine, user_id, user_settings, temporary, workspace_id + ) @router.post("/graph/{graph_id}/update") @@ -345,9 +353,10 @@ async def route_get_folders( async def route_create_folder( request: Request, name: str, + workspace_id: str | None = None, user_id: str = Depends(get_current_user_id), ) -> Folder: - return await create_folder(request.app.state.pg_engine, user_id, name) + return await create_folder(request.app.state.pg_engine, user_id, name, workspace_id) @router.patch("/folders/{folder_id}") @@ -379,6 +388,48 @@ async def route_move_graph( request: Request, graph_id: str, folder_id: str | None = None, # None means move to root + workspace_id: str | None = None, user_id: str = Depends(get_current_user_id), ) -> Graph: + if workspace_id: + return await move_graph_to_workspace(request.app.state.pg_engine, graph_id, workspace_id) return await move_graph_to_folder(request.app.state.pg_engine, graph_id, folder_id) + + +# --- Workspace Routes --- + + +@router.get("/workspaces") +async def route_get_workspaces( + request: Request, + user_id: str = Depends(get_current_user_id), +) -> list[Workspace]: + return await get_user_workspaces(request.app.state.pg_engine, user_id) + + +@router.post("/workspaces") +async def route_create_workspace( + request: Request, + name: str, + user_id: str = Depends(get_current_user_id), +) -> Workspace: + return await create_workspace(request.app.state.pg_engine, user_id, name) + + +@router.patch("/workspaces/{workspace_id}") +async def route_update_workspace( + request: Request, + workspace_id: str, + name: str, + user_id: str = Depends(get_current_user_id), +) -> Workspace: + return await update_workspace(request.app.state.pg_engine, workspace_id, name) + + +@router.delete("/workspaces/{workspace_id}") +async def route_delete_workspace( + request: Request, + workspace_id: str, + user_id: str = Depends(get_current_user_id), +) -> None: + await delete_workspace(request.app.state.pg_engine, workspace_id) diff --git a/api/migrations/versions/fd447b227777_add_workspace_table.py b/api/migrations/versions/fd447b227777_add_workspace_table.py new file mode 100644 index 00000000..d429e6cd --- /dev/null +++ b/api/migrations/versions/fd447b227777_add_workspace_table.py @@ -0,0 +1,114 @@ +# flake8: noqa +"""add workspace table + +Revision ID: fd447b227777 +Revises: c8c2d84a3b86 +Create Date: 2026-01-02 17:26:53.779164 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "fd447b227777" +down_revision = "c8c2d84a3b86" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create workspaces table + op.create_table( + "workspaces", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("uuid_generate_v4()"), + nullable=False, + ), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + # 2. Add workspace_id to folders + op.add_column( + "folders", sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=True) + ) + op.create_foreign_key( + "fk_folders_workspace_id", + "folders", + "workspaces", + ["workspace_id"], + ["id"], + ondelete="CASCADE", + ) + + # 3. Add workspace_id to graphs + op.add_column("graphs", sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=True)) + op.create_foreign_key( + "fk_graphs_workspace_id", + "graphs", + "workspaces", + ["workspace_id"], + ["id"], + ondelete="SET NULL", + ) + + # 4. Data Migration + # Create default workspaces for all existing users + op.execute( + """ + INSERT INTO workspaces (id, user_id, name, created_at, updated_at) + SELECT uuid_generate_v4(), id, 'Default', now(), now() + FROM users + """ + ) + + # Link existing folders to the new default workspace of their owner + op.execute( + """ + UPDATE folders f + SET workspace_id = w.id + FROM workspaces w + WHERE f.user_id = w.user_id + """ + ) + + # Link existing graphs to the new default workspace of their owner + op.execute( + """ + UPDATE graphs g + SET workspace_id = w.id + FROM workspaces w + WHERE g.user_id = w.user_id + """ + ) + + +def downgrade() -> None: + # 1. Drop foreign keys and columns + op.drop_constraint("fk_graphs_workspace_id", "graphs", type_="foreignkey") + op.drop_column("graphs", "workspace_id") + + op.drop_constraint("fk_folders_workspace_id", "folders", type_="foreignkey") + op.drop_column("folders", "workspace_id") + + # 2. Drop workspaces table + op.drop_table("workspaces") diff --git a/ui/app/assets/icons/MdiBriefcaseOutline.svg b/ui/app/assets/icons/MdiBriefcaseOutline.svg new file mode 100644 index 00000000..0a2400f8 --- /dev/null +++ b/ui/app/assets/icons/MdiBriefcaseOutline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/app/assets/icons/MdiFolderRemoveOutline.svg b/ui/app/assets/icons/MdiFolderRemoveOutline.svg new file mode 100644 index 00000000..38be1ab6 --- /dev/null +++ b/ui/app/assets/icons/MdiFolderRemoveOutline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/app/components/ui/sidebar-history/actions.vue b/ui/app/components/ui/sidebar-history/actions.vue index 565ce12a..87dc74e0 100644 --- a/ui/app/components/ui/sidebar-history/actions.vue +++ b/ui/app/components/ui/sidebar-history/actions.vue @@ -1,5 +1,6 @@ + + diff --git a/ui/app/components/ui/sidebar-history/submenu.vue b/ui/app/components/ui/sidebar-history/submenu.vue index 8c58b5f6..ce6a5e6d 100644 --- a/ui/app/components/ui/sidebar-history/submenu.vue +++ b/ui/app/components/ui/sidebar-history/submenu.vue @@ -5,13 +5,17 @@ defineProps<{ items: { label: string; icon?: string; - action: () => void; - isActive?: boolean; + action?: () => void; class?: string; + isHeader?: boolean; }[]; }>(); const isOpen = ref(false); + +const executeAction = (action?: () => void) => { + if (action) action(); +};