import re import secrets from datetime import UTC, datetime, timedelta from sqlalchemy import or_ from sqlalchemy.orm import Session from app.models.tenant import Organization, TenantAuditEvent, Workspace, WorkspaceInvitation, WorkspaceMembership from app.models.user import User def slugify(value: str) -> str: return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:170] or "workspace" def get_membership(db: Session, workspace_id: int, user_id: int) -> WorkspaceMembership | None: return db.query(WorkspaceMembership).filter_by(workspace_id=workspace_id, user_id=user_id).first() def can_manage(role: str | None) -> bool: return role in {"owner", "admin"} def audit(db: Session, organization_id: int, workspace_id: int | None, actor_id: int, action: str, target: str) -> None: db.add(TenantAuditEvent( organization_id=organization_id, workspace_id=workspace_id, actor_id=actor_id, action=action, target=target, )) def create_workspace(db: Session, owner: User, data) -> Workspace: org_slug = slugify(data.organization_name) if db.query(Organization).filter_by(slug=org_slug).first(): org_slug = f"{org_slug}-{secrets.token_hex(3)}" organization = Organization(name=data.organization_name.strip(), slug=org_slug, owner_id=owner.id) db.add(organization) db.flush() workspace_slug = slugify(data.workspace_name) workspace = Workspace( organization_id=organization.id, name=data.workspace_name.strip(), slug=workspace_slug, ) db.add(workspace) db.flush() db.add(WorkspaceMembership(workspace_id=workspace.id, user_id=owner.id, role="owner")) audit(db, organization.id, workspace.id, owner.id, "workspace.created", workspace.name) db.commit() db.refresh(workspace) return workspace def ensure_personal_workspace(db: Session, owner: User) -> Workspace: existing = ( db.query(Workspace) .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) .filter(WorkspaceMembership.user_id == owner.id) .order_by(Workspace.created_at.asc()) .first() ) if existing is not None: return existing label = (owner.full_name or owner.username or "Personal").strip() org_slug = f"{slugify(owner.username)}-personal" if db.query(Organization).filter_by(slug=org_slug).first(): org_slug = f"{org_slug}-{secrets.token_hex(3)}" organization = Organization(name=f"{label}'s organization", slug=org_slug, owner_id=owner.id) db.add(organization) db.flush() workspace = Workspace( organization_id=organization.id, name="Personal workspace", slug="personal-workspace", ) db.add(workspace) db.flush() db.add(WorkspaceMembership(workspace_id=workspace.id, user_id=owner.id, role="owner")) audit(db, organization.id, workspace.id, owner.id, "workspace.bootstrapped", workspace.name) db.commit() db.refresh(workspace) return workspace def list_workspaces(db: Session, user_id: int): return ( db.query(Workspace, Organization, WorkspaceMembership.role) .join(Organization, Organization.id == Workspace.organization_id) .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) .filter(WorkspaceMembership.user_id == user_id) .order_by(Workspace.created_at.asc()) .all() ) def get_workspace(db: Session, workspace_id: int, user_id: int): return ( db.query(Workspace, Organization, WorkspaceMembership.role) .join(Organization, Organization.id == Workspace.organization_id) .join(WorkspaceMembership, WorkspaceMembership.workspace_id == Workspace.id) .filter(Workspace.id == workspace_id, WorkspaceMembership.user_id == user_id) .first() ) def list_members(db: Session, workspace_id: int): return ( db.query(User, WorkspaceMembership) .join(WorkspaceMembership, WorkspaceMembership.user_id == User.id) .filter(WorkspaceMembership.workspace_id == workspace_id) .order_by(User.full_name.asc()) .all() ) def invite_member(db: Session, workspace_id: int, inviter_id: int, email: str, role: str): token = secrets.token_urlsafe(32) invitation = WorkspaceInvitation( workspace_id=workspace_id, inviter_id=inviter_id, email=email.lower(), role=role, token=token, expires_at=datetime.now(UTC) + timedelta(days=7), ) db.add(invitation) workspace = db.query(Workspace).filter_by(id=workspace_id).first() if workspace: audit(db, workspace.organization_id, workspace_id, inviter_id, "invitation.created", email.lower()) db.commit() db.refresh(invitation) return invitation def revoke_invitation(db: Session, workspace_id: int, invitation_id: int, actor_id: int) -> bool: invitation = db.query(WorkspaceInvitation).filter_by(id=invitation_id, workspace_id=workspace_id, accepted=False).first() if invitation is None: return False invitation.expires_at = datetime.now(UTC) workspace = db.query(Workspace).filter_by(id=workspace_id).first() if workspace: audit(db, workspace.organization_id, workspace_id, actor_id, "invitation.revoked", invitation.email) db.commit() return True def transfer_ownership(db: Session, workspace_id: int, current_owner_id: int, next_owner_id: int) -> bool: current = get_membership(db, workspace_id, current_owner_id) target = get_membership(db, workspace_id, next_owner_id) workspace = db.query(Workspace).filter_by(id=workspace_id).first() if not current or current.role != "owner" or not target or not workspace: return False current.role = "admin" target.role = "owner" organization = db.query(Organization).filter_by(id=workspace.organization_id).first() if organization and organization.owner_id == current_owner_id: organization.owner_id = next_owner_id audit(db, workspace.organization_id, workspace_id, current_owner_id, "workspace.ownership_transferred", str(next_owner_id)) db.commit() return True def list_audit_events(db: Session, workspace_id: int, limit: int = 100): return db.query(TenantAuditEvent).filter_by(workspace_id=workspace_id).order_by(TenantAuditEvent.created_at.desc()).limit(limit).all() def accept_invitation(db: Session, token: str, user: User) -> WorkspaceMembership | None: invitation = db.query(WorkspaceInvitation).filter_by(token=token, accepted=False).first() if invitation is None or invitation.expires_at < datetime.now(UTC) or invitation.email != user.email.lower(): return None membership = get_membership(db, invitation.workspace_id, user.id) if membership is None: membership = WorkspaceMembership(workspace_id=invitation.workspace_id, user_id=user.id, role=invitation.role) db.add(membership) invitation.accepted = True db.commit() db.refresh(membership) return membership def update_member_role(db: Session, workspace_id: int, target_id: int, role: str) -> WorkspaceMembership | None: membership = get_membership(db, workspace_id, target_id) if membership is None or membership.role == "owner": return None membership.role = role db.commit() db.refresh(membership) return membership