diff --git a/alembic/versions/0072_workspaces.py b/alembic/versions/0072_workspaces.py new file mode 100644 index 0000000..38ebe53 --- /dev/null +++ b/alembic/versions/0072_workspaces.py @@ -0,0 +1,104 @@ +"""Migration: Create workspace tables. + +Revision ID: 0072 +Revises: 0071 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB + +revision = "0072" +down_revision = "0071" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # workspaces + op.create_table( + "workspaces", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("icon", sa.String(50), nullable=False, server_default="LayoutGrid"), + sa.Column("description", sa.String(500), nullable=True), + sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("created_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.UniqueConstraint("tenant_id", "name", name="uq_workspaces_tenant_name"), + ) + op.create_index("ix_workspaces_tenant", "workspaces", ["tenant_id"]) + op.execute( + "CREATE UNIQUE INDEX uq_workspace_default_per_tenant " + "ON workspaces (tenant_id) WHERE is_default = true" + ) + + # workspace_modules + op.create_table( + "workspace_modules", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("module_key", sa.String(100), nullable=False), + sa.Column("is_visible", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column("menu_order", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.UniqueConstraint("tenant_id", "workspace_id", "module_key", name="uq_wm_tenant_workspace_module"), + ) + op.create_index("ix_wm_workspace", "workspace_modules", ["tenant_id", "workspace_id", "menu_order"]) + + # workspace_users + op.create_table( + "workspace_users", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("role", sa.String(20), nullable=False, server_default="member"), + sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("assigned_by", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.UniqueConstraint("tenant_id", "workspace_id", "user_id", name="uq_wu_tenant_workspace_user"), + sa.CheckConstraint("role IN ('member', 'manager')", name="ck_wu_role"), + ) + op.create_index("ix_wu_workspace", "workspace_users", ["tenant_id", "workspace_id"]) + op.create_index("ix_wu_user", "workspace_users", ["tenant_id", "user_id"]) + + # workspace_widgets + op.create_table( + "workspace_widgets", + sa.Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("workspace_id", PGUUID(as_uuid=True), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("widget_key", sa.String(100), nullable=False), + sa.Column("position_x", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("position_y", sa.Integer, nullable=False, server_default=sa.text("0")), + sa.Column("width", sa.Integer, nullable=False, server_default=sa.text("1")), + sa.Column("height", sa.Integer, nullable=False, server_default=sa.text("1")), + sa.Column("config", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + ) + op.create_index("ix_ww_workspace", "workspace_widgets", ["tenant_id", "workspace_id"]) + + # RLS on all workspace tables + for table in ["workspaces", "workspace_modules", "workspace_users", "workspace_widgets"]: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY {table}_tenant_isolation ON {table} " + "FOR ALL " + "USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid) " + "WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid)" + ) + op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO crm_api, crm_worker") + + +def downgrade() -> None: + for table in ["workspace_widgets", "workspace_users", "workspace_modules", "workspaces"]: + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table}") + op.drop_table(table) diff --git a/app/main.py b/app/main.py index eaafe2a..7776338 100644 --- a/app/main.py +++ b/app/main.py @@ -58,6 +58,7 @@ from app.routes import ( custom_field_definitions, custom_fields, saved_filters, + workspaces, saved_views, webhooks, backups, @@ -414,6 +415,7 @@ def create_app() -> FastAPI: app.include_router(errors.router) app.include_router(guest_auth.router) app.include_router(guests.router) + app.include_router(workspaces.router) # ── Register plugin routes for all built-in plugins ── # Routes are registered at app creation time so OpenAPI docs are complete. diff --git a/app/models/__init__.py b/app/models/__init__.py index d5103d4..b6adff6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -85,3 +85,4 @@ __all__ = [ "SavedView", ] from app.models.entity_attachment import EntityAttachment # noqa: F401 +from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget # noqa: F401 diff --git a/app/models/workspace.py b/app/models/workspace.py new file mode 100644 index 0000000..42598f4 --- /dev/null +++ b/app/models/workspace.py @@ -0,0 +1,174 @@ +"""Workspace models — UI/navigation context only. + +Workspaces control which modules, menu items, calendar views, contact folders, +saved views, and dashboard widgets are visible to a user. They NEVER affect +RBAC, ABAC, entity permissions, owner/sharing rights, tenant memberships, +RLS policies, or actual data access rights. + +See: docs/security_kernel.md for the permission intersection rule. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +class Workspace(Base, TenantMixin): + """A workspace is a UI/navigation context for a user. + + It defines which modules are visible, which dashboard widgets appear, + and how the sidebar is configured. It does NOT affect data access rights. + """ + + __tablename__ = "workspaces" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_workspaces_tenant_name"), + # Only one default workspace per tenant + Index( + "uq_workspace_default_per_tenant", + "tenant_id", + unique=True, + postgresql_where=func.text("is_default = true"), + ), + Index("ix_workspaces_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + icon: Mapped[str] = mapped_column(String(50), nullable=False, default="LayoutGrid") + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + onupdate=func.now(), + ) + + +class WorkspaceModule(Base, TenantMixin): + """Which modules are visible in a workspace and their configuration.""" + + __tablename__ = "workspace_modules" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "workspace_id", "module_key", name="uq_wm_tenant_workspace_module" + ), + Index("ix_wm_workspace", "tenant_id", "workspace_id", "menu_order"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + module_key: Mapped[str] = mapped_column(String(100), nullable=False) + is_visible: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + menu_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + onupdate=func.now(), + ) + + +class WorkspaceUser(Base, TenantMixin): + """User assignment to a workspace with role (member or manager).""" + + __tablename__ = "workspace_users" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "workspace_id", "user_id", name="uq_wu_tenant_workspace_user" + ), + CheckConstraint("role IN ('member', 'manager')", name="ck_wu_role"), + Index("ix_wu_workspace", "tenant_id", "workspace_id"), + Index("ix_wu_user", "tenant_id", "user_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + assigned_by: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + assigned_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + +class WorkspaceWidget(Base, TenantMixin): + """Dashboard widget configuration per workspace. + + Multiple instances of the same widget type can exist in the same workspace. + No UNIQUE constraint on (workspace_id, widget_key) — allows duplicates. + """ + + __tablename__ = "workspace_widgets" + __table_args__ = ( + Index("ix_ww_workspace", "tenant_id", "workspace_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + widget_key: Mapped[str] = mapped_column(String(100), nullable=False) + position_x: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + position_y: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + width: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + height: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), + onupdate=func.now(), + ) diff --git a/app/routes/workspaces.py b/app/routes/workspaces.py new file mode 100644 index 0000000..cf5c8ba --- /dev/null +++ b/app/routes/workspaces.py @@ -0,0 +1,250 @@ +"""Workspace API routes. + +Workspaces are UI/navigation context only — they never affect permissions. +The X-Workspace-ID header is used for workspace context (per-tab, not session). +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Header, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db +from app.deps import require_permission +from app.services import workspace_service + +router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) + + +class WorkspaceCreate(BaseModel): + name: str + icon: str = "LayoutGrid" + description: str | None = None + is_default: bool = False + + +class WorkspaceUpdate(BaseModel): + name: str | None = None + icon: str | None = None + description: str | None = None + is_default: bool | None = None + is_active: bool | None = None + + +class ModuleAssignment(BaseModel): + module_key: str + is_visible: bool = True + menu_order: int = 0 + config: dict[str, Any] = {} + + +class SetModules(BaseModel): + modules: list[ModuleAssignment] + + +class UserAssignment(BaseModel): + user_id: str + role: str = "member" + + +@router.get("") +async def list_workspaces( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:read")), +): + """List all workspaces for the tenant.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + return await workspace_service.list_workspaces(db, tenant_id) + + +@router.get("/my") +async def my_workspaces( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:read")), +): + """Get workspaces assigned to the current user.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + return await workspace_service.get_my_workspaces(db, tenant_id, user_id) + + +@router.get("/context") +async def workspace_context( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:read")), + x_workspace_id: str | None = Header(None, alias="X-Workspace-ID"), +): + """Get workspace context for the current user (modules, widgets). + + Uses X-Workspace-ID header for tab-local workspace selection. + Falls back to user's default workspace if no header. + """ + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + is_admin = current_user.get("is_system_admin", False) + + workspace_id = None + if x_workspace_id: + try: + workspace_id = uuid.UUID(x_workspace_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid X-Workspace-ID", "code": "invalid_id"}) + else: + # Find user's default workspace + my = await workspace_service.get_my_workspaces(db, tenant_id, user_id) + for ws in my["items"]: + if ws.get("is_user_default"): + workspace_id = uuid.UUID(ws["id"]) + break + if workspace_id is None and my["items"]: + workspace_id = uuid.UUID(my["items"][0]["id"]) + + if workspace_id is None: + return {"workspace_id": None, "modules": [], "widgets": []} + + ctx = await workspace_service.get_workspace_context(db, tenant_id, user_id, workspace_id) + if ctx is None and not is_admin: + # User not assigned — return empty context + return {"workspace_id": str(workspace_id), "modules": [], "widgets": [], "error": "not_assigned"} + elif ctx is None and is_admin: + # Admin can see any workspace — get without user check + ws_data = await workspace_service.get_workspace(db, tenant_id, workspace_id) + if ws_data is None: + return {"workspace_id": None, "modules": [], "widgets": []} + return { + "workspace_id": ws_data["id"], + "name": ws_data["name"], + "icon": ws_data["icon"], + "role": "admin", + "modules": ws_data.get("modules", []), + "widgets": [], + } + return ctx + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_workspace( + body: WorkspaceCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:create")), +): + """Create a new workspace (Tenant Admin or System Admin).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + return await workspace_service.create_workspace( + db, tenant_id, user_id, body.name, body.icon, body.description, body.is_default + ) + + +@router.get("/{workspace_id}") +async def get_workspace( + workspace_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:read")), +): + """Get a single workspace with modules and user count.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wid = uuid.UUID(workspace_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) + result = await workspace_service.get_workspace(db, tenant_id, wid) + if result is None: + raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) + return result + + +@router.put("/{workspace_id}") +async def update_workspace( + workspace_id: str, + body: WorkspaceUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:update")), +): + """Update a workspace.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wid = uuid.UUID(workspace_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) + result = await workspace_service.update_workspace( + db, tenant_id, wid, body.name, body.icon, body.description, body.is_default, body.is_active + ) + if result is None: + raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) + return result + + +@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_workspace( + workspace_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:delete")), +): + """Delete a workspace (soft delete).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wid = uuid.UUID(workspace_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) + deleted = await workspace_service.delete_workspace(db, tenant_id, wid) + if not deleted: + raise HTTPException(404, detail={"detail": "Workspace not found", "code": "not_found"}) + + +@router.post("/{workspace_id}/modules") +async def set_modules( + workspace_id: str, + body: SetModules, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:configure_modules")), +): + """Set modules for a workspace (replaces all existing).""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wid = uuid.UUID(workspace_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid workspace_id", "code": "invalid_id"}) + modules = [{"module_key": m.module_key, "is_visible": m.is_visible, "menu_order": m.menu_order, "config": m.config} for m in body.modules] + return await workspace_service.set_workspace_modules(db, tenant_id, wid, modules) + + +@router.post("/{workspace_id}/users", status_code=status.HTTP_201_CREATED) +async def assign_user( + workspace_id: str, + body: UserAssignment, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:assign_users")), +): + """Assign a user to a workspace.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + try: + wid = uuid.UUID(workspace_id) + target_uid = uuid.UUID(body.user_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) + return await workspace_service.assign_user(db, tenant_id, wid, target_uid, body.role, assigned_by=user_id) + + +@router.delete("/{workspace_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user( + workspace_id: str, + user_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_permission("workspaces:assign_users")), +): + """Remove a user from a workspace.""" + tenant_id = uuid.UUID(current_user["tenant_id"]) + try: + wid = uuid.UUID(workspace_id) + uid = uuid.UUID(user_id) + except ValueError: + raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"}) + removed = await workspace_service.remove_user(db, tenant_id, wid, uid) + if not removed: + raise HTTPException(404, detail={"detail": "User not assigned to this workspace", "code": "not_found"}) diff --git a/app/services/workspace_service.py b/app/services/workspace_service.py new file mode 100644 index 0000000..6761343 --- /dev/null +++ b/app/services/workspace_service.py @@ -0,0 +1,425 @@ +"""Workspace service — CRUD, module config, user assignment, widgets. + +Workspaces are UI/navigation context only. They never affect permissions. +See: docs/security_kernel.md +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import select, update, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.workspace import Workspace, WorkspaceModule, WorkspaceUser, WorkspaceWidget + + +def _workspace_to_dict(ws: Workspace, modules: list[WorkspaceModule] | None = None, user_count: int = 0) -> dict[str, Any]: + return { + "id": str(ws.id), + "name": ws.name, + "icon": ws.icon, + "description": ws.description, + "is_default": ws.is_default, + "is_active": ws.is_active, + "created_by": str(ws.created_by) if ws.created_by else None, + "created_at": ws.created_at.isoformat() if ws.created_at else None, + "updated_at": ws.updated_at.isoformat() if ws.updated_at else None, + "modules": [ + { + "id": str(m.id), + "module_key": m.module_key, + "is_visible": m.is_visible, + "menu_order": m.menu_order, + "config": m.config or {}, + } + for m in (modules or []) + ], + "user_count": user_count, + } + + +async def list_workspaces( + db: AsyncSession, tenant_id: uuid.UUID +) -> dict[str, Any]: + """List all workspaces for a tenant.""" + q = select(Workspace).where( + Workspace.tenant_id == tenant_id, + Workspace.is_active == True, # noqa: E712 + ).order_by(Workspace.name) + result = await db.execute(q) + workspaces = result.scalars().all() + + items = [] + for ws in workspaces: + # Count users + count_q = select(func.count()).select_from(WorkspaceUser).where( + WorkspaceUser.workspace_id == ws.id, + WorkspaceUser.tenant_id == tenant_id, + ) + count_result = await db.execute(count_q) + user_count = count_result.scalar() or 0 + items.append(_workspace_to_dict(ws, user_count=user_count)) + + return {"items": items, "total": len(items)} + + +async def get_workspace( + db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID +) -> dict[str, Any] | None: + """Get a single workspace with modules and user count.""" + q = select(Workspace).where( + Workspace.id == workspace_id, + Workspace.tenant_id == tenant_id, + ) + result = await db.execute(q) + ws = result.scalar_one_or_none() + if ws is None: + return None + + # Get modules + mod_q = select(WorkspaceModule).where( + WorkspaceModule.workspace_id == workspace_id, + WorkspaceModule.tenant_id == tenant_id, + ).order_by(WorkspaceModule.menu_order) + mod_result = await db.execute(mod_q) + modules = mod_result.scalars().all() + + # Count users + count_q = select(func.count()).select_from(WorkspaceUser).where( + WorkspaceUser.workspace_id == workspace_id, + WorkspaceUser.tenant_id == tenant_id, + ) + count_result = await db.execute(count_q) + user_count = count_result.scalar() or 0 + + return _workspace_to_dict(ws, modules=modules, user_count=user_count) + + +async def create_workspace( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + name: str, + icon: str = "LayoutGrid", + description: str | None = None, + is_default: bool = False, +) -> dict[str, Any]: + """Create a new workspace.""" + ws = Workspace( + tenant_id=tenant_id, + name=name, + icon=icon, + description=description, + is_default=is_default, + is_active=True, + created_by=user_id, + ) + db.add(ws) + await db.flush() + await db.refresh(ws) + + # If this is the default workspace, unset others + if is_default: + await db.execute( + update(Workspace) + .where( + Workspace.tenant_id == tenant_id, + Workspace.id != ws.id, + ) + .values(is_default=False) + ) + + # Auto-assign creator as manager + wu = WorkspaceUser( + tenant_id=tenant_id, + workspace_id=ws.id, + user_id=user_id, + role="manager", + is_default=is_default, + assigned_by=user_id, + ) + db.add(wu) + await db.flush() + + return _workspace_to_dict(ws, user_count=1) + + +async def update_workspace( + db: AsyncSession, + tenant_id: uuid.UUID, + workspace_id: uuid.UUID, + name: str | None = None, + icon: str | None = None, + description: str | None = None, + is_default: bool | None = None, + is_active: bool | None = None, +) -> dict[str, Any] | None: + """Update a workspace.""" + q = select(Workspace).where( + Workspace.id == workspace_id, + Workspace.tenant_id == tenant_id, + ) + result = await db.execute(q) + ws = result.scalar_one_or_none() + if ws is None: + return None + + if name is not None: + ws.name = name + if icon is not None: + ws.icon = icon + if description is not None: + ws.description = description + if is_active is not None: + ws.is_active = is_active + if is_default is True: + # Unset other defaults + await db.execute( + update(Workspace) + .where( + Workspace.tenant_id == tenant_id, + Workspace.id != ws.id, + ) + .values(is_default=False) + ) + ws.is_default = True + elif is_default is False: + ws.is_default = False + + await db.flush() + await db.refresh(ws) + return _workspace_to_dict(ws) + + +async def delete_workspace( + db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID +) -> bool: + """Delete a workspace (soft delete by setting is_active=False).""" + q = select(Workspace).where( + Workspace.id == workspace_id, + Workspace.tenant_id == tenant_id, + ) + result = await db.execute(q) + ws = result.scalar_one_or_none() + if ws is None: + return False + ws.is_active = False + await db.flush() + return True + + +async def set_workspace_modules( + db: AsyncSession, + tenant_id: uuid.UUID, + workspace_id: uuid.UUID, + modules: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Set the modules for a workspace. Replaces all existing modules.""" + # Delete existing modules + existing_q = select(WorkspaceModule).where( + WorkspaceModule.workspace_id == workspace_id, + WorkspaceModule.tenant_id == tenant_id, + ) + existing = await db.execute(existing_q) + for m in existing.scalars().all(): + await db.delete(m) + + # Insert new modules + result = [] + for mod in modules: + wm = WorkspaceModule( + tenant_id=tenant_id, + workspace_id=workspace_id, + module_key=mod["module_key"], + is_visible=mod.get("is_visible", True), + menu_order=mod.get("menu_order", 0), + config=mod.get("config", {}), + ) + db.add(wm) + await db.flush() + await db.refresh(wm) + result.append({ + "id": str(wm.id), + "module_key": wm.module_key, + "is_visible": wm.is_visible, + "menu_order": wm.menu_order, + "config": wm.config or {}, + }) + + return result + + +async def assign_user( + db: AsyncSession, + tenant_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + role: str = "member", + assigned_by: uuid.UUID | None = None, +) -> dict[str, Any]: + """Assign a user to a workspace.""" + wu = WorkspaceUser( + tenant_id=tenant_id, + workspace_id=workspace_id, + user_id=user_id, + role=role, + is_default=False, + assigned_by=assigned_by, + ) + db.add(wu) + await db.flush() + await db.refresh(wu) + return { + "id": str(wu.id), + "workspace_id": str(wu.workspace_id), + "user_id": str(wu.user_id), + "role": wu.role, + "is_default": wu.is_default, + "assigned_at": wu.assigned_at.isoformat() if wu.assigned_at else None, + } + + +async def remove_user( + db: AsyncSession, tenant_id: uuid.UUID, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> bool: + """Remove a user from a workspace.""" + q = select(WorkspaceUser).where( + WorkspaceUser.workspace_id == workspace_id, + WorkspaceUser.user_id == user_id, + WorkspaceUser.tenant_id == tenant_id, + ) + result = await db.execute(q) + wu = result.scalar_one_or_none() + if wu is None: + return False + await db.delete(wu) + await db.flush() + return True + + +async def get_my_workspaces( + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID +) -> dict[str, Any]: + """Get workspaces assigned to the current user.""" + q = ( + select(Workspace, WorkspaceUser) + .join(WorkspaceUser, WorkspaceUser.workspace_id == Workspace.id) + .where( + WorkspaceUser.user_id == user_id, + Workspace.tenant_id == tenant_id, + Workspace.is_active == True, # noqa: E712 + ) + .order_by(Workspace.name) + ) + result = await db.execute(q) + rows = result.all() + + items = [] + for ws, wu in rows: + # Get modules for this workspace + mod_q = select(WorkspaceModule).where( + WorkspaceModule.workspace_id == ws.id, + WorkspaceModule.tenant_id == tenant_id, + WorkspaceModule.is_visible == True, # noqa: E712 + ).order_by(WorkspaceModule.menu_order) + mod_result = await db.execute(mod_q) + modules = mod_result.scalars().all() + + items.append({ + "id": str(ws.id), + "name": ws.name, + "icon": ws.icon, + "description": ws.description, + "is_default": ws.is_default, + "role": wu.role, + "is_user_default": wu.is_default, + "modules": [ + { + "module_key": m.module_key, + "menu_order": m.menu_order, + "config": m.config or {}, + } + for m in modules + ], + }) + + return {"items": items, "total": len(items)} + + +async def get_workspace_context( + db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> dict[str, Any] | None: + """Get workspace context for a user — modules, widgets, config. + + Validates: + - Workspace belongs to tenant + - User is assigned or is system admin / tenant admin + - Workspace is active + """ + # Check workspace exists and is active + ws_q = select(Workspace).where( + Workspace.id == workspace_id, + Workspace.tenant_id == tenant_id, + Workspace.is_active == True, # noqa: E712 + ) + ws_result = await db.execute(ws_q) + ws = ws_result.scalar_one_or_none() + if ws is None: + return None + + # Check user is assigned + wu_q = select(WorkspaceUser).where( + WorkspaceUser.workspace_id == workspace_id, + WorkspaceUser.user_id == user_id, + WorkspaceUser.tenant_id == tenant_id, + ) + wu_result = await db.execute(wu_q) + wu = wu_result.scalar_one_or_none() + if wu is None: + return None # User not assigned — caller can check is_system_admin + + # Get visible modules + mod_q = select(WorkspaceModule).where( + WorkspaceModule.workspace_id == workspace_id, + WorkspaceModule.tenant_id == tenant_id, + WorkspaceModule.is_visible == True, # noqa: E712 + ).order_by(WorkspaceModule.menu_order) + mod_result = await db.execute(mod_q) + modules = mod_result.scalars().all() + + # Get widgets + widget_q = select(WorkspaceWidget).where( + WorkspaceWidget.workspace_id == workspace_id, + WorkspaceWidget.tenant_id == tenant_id, + ).order_by(WorkspaceWidget.position_y, WorkspaceWidget.position_x) + widget_result = await db.execute(widget_q) + widgets = widget_result.scalars().all() + + return { + "workspace_id": str(ws.id), + "name": ws.name, + "icon": ws.icon, + "role": wu.role, + "modules": [ + { + "module_key": m.module_key, + "menu_order": m.menu_order, + "config": m.config or {}, + } + for m in modules + ], + "widgets": [ + { + "id": str(w.id), + "widget_key": w.widget_key, + "position_x": w.position_x, + "position_y": w.position_y, + "width": w.width, + "height": w.height, + "config": w.config or {}, + } + for w in widgets + ], + }