diff --git a/api/app/database/pg/graph_ops/graph_crud.py b/api/app/database/pg/graph_ops/graph_crud.py index df566f46..8edfbe58 100644 --- a/api/app/database/pg/graph_ops/graph_crud.py +++ b/api/app/database/pg/graph_ops/graph_crud.py @@ -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_ @@ -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) @@ -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: @@ -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)) @@ -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"]) @@ -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] @@ -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. @@ -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, @@ -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) 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/database/pg/user_ops/user_crud.py b/api/app/database/pg/user_ops/user_crud.py index 0683ba4d..d4bec055 100644 --- a/api/app/database/pg/user_ops/user_crud.py +++ b/api/app/database/pg/user_ops/user_crud.py @@ -1,6 +1,6 @@ import logging -from database.pg.models import User +from database.pg.models import User, Workspace from fastapi import HTTPException from models.auth import ProviderEnum from pydantic import BaseModel, Field @@ -28,6 +28,7 @@ async def create_user_from_provider( ) -> User: """ Create a new user in the database from OAuth provider data. + Automatically creates a default 'Home' workspace. Args: pg_engine (SQLAlchemyAsyncEngine): The SQLAlchemy async engine instance. @@ -58,8 +59,15 @@ async def create_user_from_provider( oauth_id=str(payload.oauth_id), ) session.add(user) - await session.commit() - return user + + await session.flush() + + # Create Default Workspace + workspace = Workspace(user_id=user.id, name="Default") + session.add(workspace) + + await session.refresh(user) + return user async def create_user_with_password( @@ -70,6 +78,7 @@ async def create_user_with_password( ) -> User: """ Create a new user with a username and password (userpass provider). + Automatically creates a default 'Home' workspace. Args: pg_engine (SQLAlchemyAsyncEngine): The SQLAlchemy async engine. @@ -84,37 +93,44 @@ async def create_user_with_password( HTTPException: If username or email is already taken. """ async with AsyncSession(pg_engine, expire_on_commit=False) as session: - async with session.begin(): - # Check for existing username or email - stmt = select(User).where( - or_( - and_(User.username == username, User.oauth_provider == "userpass"), - User.email == email, + try: + async with session.begin(): + # Check for existing username or email + stmt = select(User).where( + or_( + and_(User.username == username, User.oauth_provider == "userpass"), + User.email == email, + ) ) - ) - result = await session.exec(stmt) # type: ignore - existing_user_row = result.first() + result = await session.exec(stmt) # type: ignore + existing_user_row = result.first() + + if existing_user_row: + existing_user = existing_user_row[0] + if existing_user.email == email: + raise HTTPException(status_code=409, detail="Email is already registered.") + raise HTTPException(status_code=409, detail="Username is already taken.") + + user = User( + username=username, + email=email, + password=hashed_password, + oauth_provider="userpass", + plan_type="free", + ) + session.add(user) - if existing_user_row: - existing_user = existing_user_row[0] - if existing_user.email == email: - raise HTTPException(status_code=409, detail="Email is already registered.") - raise HTTPException(status_code=409, detail="Username is already taken.") + await session.flush() - user = User( - username=username, - email=email, - password=hashed_password, - oauth_provider="userpass", - plan_type="free", - ) - session.add(user) - try: - await session.commit() - except IntegrityError: - await session.rollback() - raise HTTPException(status_code=409, detail="Username or Email already exists.") - return user + # Create Default Workspace + workspace = Workspace(user_id=user.id, name="Default") + session.add(workspace) + + except IntegrityError: + raise HTTPException(status_code=409, detail="Username or Email already exists.") + + await session.refresh(user) + return user async def get_user_by_provider_id( diff --git a/api/app/routers/graph.py b/api/app/routers/graph.py index 0b384b39..d4e857f0 100644 --- a/api/app/routers/graph.py +++ b/api/app/routers/graph.py @@ -8,18 +8,24 @@ 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_folder_workspace, + 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 +79,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 +96,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 +354,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}") @@ -356,12 +366,15 @@ async def route_update_folder( folder_id: str, name: str | None = None, color: str | None = None, + workspace_id: str | None = None, user_id: str = Depends(get_current_user_id), ) -> Folder: if name is not None: return await update_folder_name(request.app.state.pg_engine, folder_id, name) if color is not None: return await update_folder_color(request.app.state.pg_engine, folder_id, color) + if workspace_id is not None: + return await update_folder_workspace(request.app.state.pg_engine, folder_id, workspace_id) raise HTTPException(status_code=400, detail="No valid update parameters provided") @@ -379,6 +392,59 @@ 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: - return await move_graph_to_folder(request.app.state.pg_engine, graph_id, folder_id) + engine = request.app.state.pg_engine + + # CASE 1: Move to specific folder + if folder_id: + if workspace_id: + pass + return await move_graph_to_folder(engine, graph_id, folder_id) + + # CASE 2: Move to workspace root (removes from folder) + if workspace_id: + return await move_graph_to_workspace(engine, graph_id, workspace_id) + + # CASE 3: Remove from folder (stay in current workspace root) + return await move_graph_to_folder(engine, graph_id, None) + + +# --- 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, user_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, user_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..e31e147f --- /dev/null +++ b/api/migrations/versions/fd447b227777_add_workspace_table.py @@ -0,0 +1,116 @@ +# flake8: noqa +"""add workspace table + +Revision ID: fd447b227777 +Revises: c8c2d84a3b86 +Create Date: 2026-01-02 17:26:53.779164 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "fd447b227777" +down_revision = "c8c2d84a3b86" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + + # 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/home/recentCanvasSection.vue b/ui/app/components/ui/home/recentCanvasSection.vue index 4c088fef..7ee958ca 100644 --- a/ui/app/components/ui/home/recentCanvasSection.vue +++ b/ui/app/components/ui/home/recentCanvasSection.vue @@ -1,5 +1,6 @@ @@ -123,6 +173,38 @@ defineExpose({