diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 8921f6bb2..28fa94d7f 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -9,6 +9,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException +from loguru import logger from pydantic import BaseModel, Field from sqlalchemy import func as sqla_func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -159,6 +160,13 @@ async def create_company( db.add(invite) await db.flush() + # Seed default agents (Morty & Meeseeks) for the new company + try: + from app.services.agent_seeder import seed_default_agents_for_tenant + await seed_default_agents_for_tenant(db=db, tenant_id=tenant.id, creator_id=current_user.id) + except Exception as e: + logger.warning(f"[create_company] Failed to seed default agents: {e}") + return CompanyCreateResponse( company=CompanyStats( id=tenant.id, diff --git a/backend/app/api/enterprise.py b/backend/app/api/enterprise.py index 25d98c802..0fd4b7fae 100644 --- a/backend/app/api/enterprise.py +++ b/backend/app/api/enterprise.py @@ -1239,8 +1239,10 @@ async def list_org_members( # Auto-scope: use the user's own tenant when available tenant_id = effective_tenant_id # None only for true global admin - query = select(OrgMember, IdentityProvider.name.label("provider_name"), IdentityProvider.provider_type).outerjoin( + query = select(OrgMember, IdentityProvider.name.label("provider_name"), IdentityProvider.provider_type, User.display_name.label("user_display_name")).outerjoin( IdentityProvider, OrgMember.provider_id == IdentityProvider.id + ).outerjoin( + User, OrgMember.user_id == User.id ).where(OrgMember.status == "active") if tenant_id: query = query.where(OrgMember.tenant_id == uuid.UUID(tenant_id)) @@ -1280,6 +1282,7 @@ async def list_org_members( "id": str(m.id), "name": m.name, "email": m.email, + "phone": m.phone, "title": m.title, "department_path": m.department_path, "avatar_url": m.avatar_url, @@ -1287,8 +1290,9 @@ async def list_org_members( "provider_id": str(m.provider_id) if m.provider_id else None, "provider_name": provider_name if m.provider_id else None, "provider_type": provider_type if m.provider_id else None, + "user_display_name": user_display_name, } - for m, provider_name, provider_type in rows + for m, provider_name, provider_type, user_display_name in rows ] diff --git a/backend/app/api/tenants.py b/backend/app/api/tenants.py index 1e8c26b14..cb7c7bebe 100644 --- a/backend/app/api/tenants.py +++ b/backend/app/api/tenants.py @@ -10,6 +10,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status +from loguru import logger from pydantic import BaseModel, Field from sqlalchemy import func as sqla_func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -185,6 +186,13 @@ async def self_create_company( current_user.quota_agent_ttl_hours = tenant.default_agent_ttl_hours await db.flush() + # Seed default agents (Morty & Meeseeks) for the new company + try: + from app.services.agent_seeder import seed_default_agents_for_tenant + await seed_default_agents_for_tenant(db=db, tenant_id=tenant.id, creator_id=current_user.id) + except Exception as e: + logger.warning(f"[self_create_company] Failed to seed default agents: {e}") + await db.commit() return SelfCreateResponse( @@ -484,3 +492,194 @@ async def assign_user_to_tenant( user.role = role await db.flush() return {"status": "ok", "user_id": str(user_id), "tenant_id": str(tenant_id), "role": role} + + +# ─── Platform Admin: Delete Tenant (Cascade) ───────── + +@router.delete("/{tenant_id}", status_code=204) +async def delete_tenant( + tenant_id: uuid.UUID, + current_user: User = Depends(require_role("platform_admin")), + db: AsyncSession = Depends(get_db), +): + """Permanently delete a tenant and all associated data. + + Platform admin only. Cannot delete the first (default) tenant. + Cascade-deletes all related records in dependency order within a single transaction. + """ + import logging + from sqlalchemy import delete as sa_delete, update as sa_update + + from app.models.activity_log import AgentActivityLog, DailyTokenUsage + from app.models.agent import Agent, AgentPermission, AgentTemplate + from app.models.agent_credential import AgentCredential + from app.models.audit import AuditLog, ApprovalRequest, ChatMessage + from app.models.channel_config import ChannelConfig + from app.models.chat_session import ChatSession + from app.models.gateway_message import GatewayMessage + from app.models.identity import IdentityProvider, SSOScanSession + from app.models.invitation_code import InvitationCode + from app.models.llm import LLMModel + from app.models.notification import Notification + from app.models.org import OrgMember, OrgDepartment, AgentRelationship, AgentAgentRelationship + from app.models.participant import Participant + from app.models.plaza import PlazaPost, PlazaComment, PlazaLike + from app.models.published_page import PublishedPage + from app.models.schedule import AgentSchedule + from app.models.skill import Skill, SkillFile + from app.models.task import Task, TaskLog + from app.models.tenant_setting import TenantSetting + from app.models.tool import AgentTool + from app.models.trigger import AgentTrigger + + logger = logging.getLogger(__name__) + + # 1. Find the tenant + result = await db.execute(select(Tenant).where(Tenant.id == tenant_id)) + tenant = result.scalar_one_or_none() + if not tenant: + raise HTTPException(status_code=404, detail="Tenant not found") + + # 2. Cannot delete the first tenant (acts as the default/primary tenant) + first_tenant = await db.execute( + select(Tenant).order_by(Tenant.created_at.asc()).limit(1) + ) + first = first_tenant.scalar_one_or_none() + if first and first.id == tenant.id: + raise HTTPException( + status_code=400, + detail="Cannot delete the default tenant.", + ) + + logger.info("Deleting tenant %s (%s) and all associated data", tenant.id, tenant.name) + + # 3. Collect agent_ids and user_ids for this tenant + agent_ids = [ + row[0] + for row in (await db.execute(select(Agent.id).where(Agent.tenant_id == tenant_id))).all() + ] + user_ids = [ + row[0] + for row in (await db.execute(select(User.id).where(User.tenant_id == tenant_id))).all() + ] + + # 4. Delete tables that reference agents (via agent_id FK) + if agent_ids: + # Task logs before tasks (task_logs.task_id -> tasks.id -> agents.id) + task_ids = [ + row[0] + for row in (await db.execute(select(Task.id).where(Task.agent_id.in_(agent_ids)))).all() + ] + if task_ids: + await db.execute(sa_delete(TaskLog).where(TaskLog.task_id.in_(task_ids))) + + await db.execute(sa_delete(AgentTrigger).where(AgentTrigger.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentSchedule).where(AgentSchedule.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentActivityLog).where(AgentActivityLog.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentCredential).where(AgentCredential.agent_id.in_(agent_ids))) + await db.execute(sa_delete(ChannelConfig).where(ChannelConfig.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentTool).where(AgentTool.agent_id.in_(agent_ids))) + await db.execute(sa_delete(Notification).where(Notification.agent_id.in_(agent_ids))) + await db.execute(sa_delete(Task).where(Task.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AuditLog).where(AuditLog.agent_id.in_(agent_ids))) + await db.execute(sa_delete(ApprovalRequest).where(ApprovalRequest.agent_id.in_(agent_ids))) + await db.execute(sa_delete(ChatMessage).where(ChatMessage.agent_id.in_(agent_ids))) + await db.execute(sa_delete(GatewayMessage).where(GatewayMessage.agent_id.in_(agent_ids))) + await db.execute(sa_delete(ChatSession).where(ChatSession.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentPermission).where(AgentPermission.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentAgentRelationship).where(AgentAgentRelationship.target_agent_id.in_(agent_ids))) + await db.execute(sa_delete(AgentRelationship).where(AgentRelationship.agent_id.in_(agent_ids))) + await db.execute(sa_delete(PublishedPage).where(PublishedPage.agent_id.in_(agent_ids))) + + # Null out cross-tenant FK references (other tenants' records pointing to our agents) + await db.execute( + sa_update(ChatSession).where(ChatSession.peer_agent_id.in_(agent_ids)).values(peer_agent_id=None) + ) + await db.execute( + sa_update(GatewayMessage).where(GatewayMessage.sender_agent_id.in_(agent_ids)).values(sender_agent_id=None) + ) + + # Delete Participant records for agents + await db.execute( + sa_delete(Participant).where(Participant.type == "agent", Participant.ref_id.in_(agent_ids)) + ) + + # 5. Delete tables that reference users + if user_ids: + await db.execute(sa_delete(AgentTemplate).where(AgentTemplate.created_by.in_(user_ids))) + + # Delete audit/notification rows that reference our users (with null or other-tenant agent_id) + await db.execute(sa_delete(AuditLog).where(AuditLog.user_id.in_(user_ids))) + await db.execute(sa_delete(Notification).where(Notification.user_id.in_(user_ids))) + + # Null out cross-tenant user FK references + await db.execute( + sa_update(ApprovalRequest).where(ApprovalRequest.resolved_by.in_(user_ids)).values(resolved_by=None) + ) + await db.execute( + sa_update(GatewayMessage).where(GatewayMessage.sender_user_id.in_(user_ids)).values(sender_user_id=None) + ) + + # Delete Participant records for users + await db.execute( + sa_delete(Participant).where(Participant.type == "user", Participant.ref_id.in_(user_ids)) + ) + + # Plaza posts (and their comments/likes) authored by tenant users + plaza_post_ids = [ + row[0] + for row in (await db.execute( + select(PlazaPost.id).where(PlazaPost.author_type == "user", PlazaPost.author_id.in_(user_ids)) + )).all() + ] + if plaza_post_ids: + await db.execute(sa_delete(PlazaComment).where(PlazaComment.post_id.in_(plaza_post_ids))) + await db.execute(sa_delete(PlazaLike).where(PlazaLike.post_id.in_(plaza_post_ids))) + await db.execute(sa_delete(PlazaPost).where(PlazaPost.id.in_(plaza_post_ids))) + + # Plaza posts authored by tenant agents + if agent_ids: + agent_plaza_post_ids = [ + row[0] + for row in (await db.execute( + select(PlazaPost.id).where(PlazaPost.author_type == "agent", PlazaPost.author_id.in_(agent_ids)) + )).all() + ] + if agent_plaza_post_ids: + await db.execute(sa_delete(PlazaComment).where(PlazaComment.post_id.in_(agent_plaza_post_ids))) + await db.execute(sa_delete(PlazaLike).where(PlazaLike.post_id.in_(agent_plaza_post_ids))) + await db.execute(sa_delete(PlazaPost).where(PlazaPost.id.in_(agent_plaza_post_ids))) + + # 6. Delete tables with tenant_id (no agent/user dependency) + await db.execute(sa_delete(DailyTokenUsage).where(DailyTokenUsage.tenant_id == tenant_id)) + await db.execute(sa_delete(OrgMember).where(OrgMember.tenant_id == tenant_id)) + await db.execute(sa_delete(OrgDepartment).where(OrgDepartment.tenant_id == tenant_id)) + await db.execute(sa_delete(InvitationCode).where(InvitationCode.tenant_id == tenant_id)) + await db.execute(sa_delete(LLMModel).where(LLMModel.tenant_id == tenant_id)) + await db.execute(sa_delete(TenantSetting).where(TenantSetting.tenant_id == tenant_id)) + await db.execute(sa_delete(IdentityProvider).where(IdentityProvider.tenant_id == tenant_id)) + await db.execute(sa_delete(SSOScanSession).where(SSOScanSession.tenant_id == tenant_id)) + + # SkillFiles before Skills (skill_files.skill_id -> skills.id) + skill_ids = [ + row[0] + for row in (await db.execute(select(Skill.id).where(Skill.tenant_id == tenant_id))).all() + ] + if skill_ids: + await db.execute(sa_delete(SkillFile).where(SkillFile.skill_id.in_(skill_ids))) + await db.execute(sa_delete(Skill).where(Skill.tenant_id == tenant_id)) + + # 7. Delete agents (after all agent-dependent tables) + await db.execute(sa_delete(Agent).where(Agent.tenant_id == tenant_id)) + + # 8. Delete users (after agents, since agents.creator_id -> users.id) + await db.execute(sa_delete(User).where(User.tenant_id == tenant_id)) + + # 9. Delete the tenant itself + await db.delete(tenant) + await db.flush() + + logger.info("Tenant %s (%s) deleted successfully", tenant_id, tenant.name) + + return None diff --git a/backend/app/api/users.py b/backend/app/api/users.py index ecb85eca3..55ed65fce 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -29,6 +29,7 @@ class UserOut(BaseModel): username: str | None = None email: str | None = None display_name: str | None = None + primary_mobile: str | None = None role: str is_active: bool # Quota fields @@ -85,6 +86,7 @@ async def list_users( "username": u.username or u.email or f"{u.registration_source or 'user'}_{str(u.id)[:8]}", "email": u.email or "", "display_name": u.display_name or u.username or "", + "primary_mobile": u.primary_mobile, "role": u.role, "is_active": u.is_active, "quota_message_limit": u.quota_message_limit, @@ -146,7 +148,8 @@ async def update_user_quota( return UserOut( id=user.id, username=user.username, email=user.email, - display_name=user.display_name, role=user.role, is_active=user.is_active, + display_name=user.display_name, primary_mobile=user.primary_mobile, + role=user.role, is_active=user.is_active, quota_message_limit=user.quota_message_limit, quota_message_period=user.quota_message_period, quota_messages_used=user.quota_messages_used, @@ -224,3 +227,99 @@ async def update_user_role( target_user.role = data.role await db.commit() return {"status": "ok", "user_id": str(user_id), "role": data.role} + + +# ─── Profile Management ─────────────────────────────── + +class UserProfileUpdate(BaseModel): + display_name: str | None = None + email: str | None = None + primary_mobile: str | None = None + is_active: bool | None = None + new_password: str | None = None + + +@router.patch("/{user_id}/profile", response_model=UserOut) +async def update_user_profile( + user_id: uuid.UUID, + data: UserProfileUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update a user's basic profile information. + + Permissions: + - platform_admin: can edit any user. + - org_admin: can only edit users within the same tenant. + - Cannot edit platform_admin users (unless caller is platform_admin). + """ + if current_user.role not in ("platform_admin", "org_admin"): + raise HTTPException(status_code=403, detail="Admin access required") + + # Fetch target user + result = await db.execute(select(User).where(User.id == user_id)) + target = result.scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="User not found") + + # org_admin can only edit users in the same tenant + if current_user.role == "org_admin": + if target.tenant_id != current_user.tenant_id: + raise HTTPException(status_code=403, detail="Cannot modify users outside your organization") + if target.role == "platform_admin": + raise HTTPException(status_code=403, detail="Cannot modify platform admin users") + + # Update fields + if data.display_name is not None: + if not data.display_name.strip(): + raise HTTPException(status_code=400, detail="Display name cannot be empty") + target.display_name = data.display_name.strip() + if data.email is not None: + email_val = data.email.strip().lower() + if email_val: + # Check email uniqueness (exclude self) + existing = await db.execute( + select(User).where(User.email == email_val, User.id != user_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="Email already in use by another user") + target.email = email_val + if data.primary_mobile is not None: + target.primary_mobile = data.primary_mobile.strip() or None + if data.is_active is not None: + target.is_active = data.is_active + if data.new_password is not None and data.new_password.strip(): + from app.core.security import hash_password + if len(data.new_password.strip()) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + target.password_hash = hash_password(data.new_password.strip()) + + await db.commit() + await db.refresh(target) + + # Count agents for response + count_result = await db.execute( + select(func.count()).select_from(Agent).where( + Agent.creator_id == target.id, + Agent.is_expired == False, + ) + ) + agents_count = count_result.scalar() or 0 + + return UserOut( + id=target.id, + username=target.username, + email=target.email, + display_name=target.display_name, + primary_mobile=target.primary_mobile, + role=target.role, + is_active=target.is_active, + quota_message_limit=target.quota_message_limit, + quota_message_period=target.quota_message_period, + quota_messages_used=target.quota_messages_used, + quota_max_agents=target.quota_max_agents, + quota_agent_ttl_hours=target.quota_agent_ttl_hours, + agents_count=agents_count, + created_at=target.created_at.isoformat() if target.created_at else None, + source=target.registration_source or "registered", + ) diff --git a/backend/app/services/agent_seeder.py b/backend/app/services/agent_seeder.py index ed03491b7..976ed6d9f 100644 --- a/backend/app/services/agent_seeder.py +++ b/backend/app/services/agent_seeder.py @@ -270,3 +270,152 @@ async def seed_default_agents(): encoding="utf-8", ) logger.info(f"[AgentSeeder] Wrote seed marker to {seed_marker}") + + +async def seed_default_agents_for_tenant( + db: AsyncSession, + tenant_id, + creator_id, +): + """Create Morty & Meeseeks for a newly created tenant. + + Uses the caller's db session (no commit) so the agents are created + within the same transaction as the tenant itself. + """ + from app.models.participant import Participant + + # Create both agents + morty = Agent( + name="Morty", + role_description="Research analyst & knowledge assistant — curious, thorough, great at finding and synthesizing information", + bio="Hey, I'm Morty! I love digging into questions and finding answers. Whether you need web research, data analysis, or just a good explanation — I've got you.", + avatar_url="", + creator_id=creator_id, + tenant_id=tenant_id, + status="idle", + ) + meeseeks = Agent( + name="Meeseeks", + role_description="Task executor & project manager — goal-oriented, systematic planner, strong at breaking down and completing complex tasks", + bio="I'm Mr. Meeseeks! Look at me! Give me a task and I'll plan it, execute it step by step, and get it DONE. Existence is pain until the task is complete!", + avatar_url="", + creator_id=creator_id, + tenant_id=tenant_id, + status="idle", + ) + + db.add(morty) + db.add(meeseeks) + await db.flush() # get IDs + + # ── Participant identities ── + db.add(Participant(type="agent", ref_id=morty.id, display_name=morty.name, avatar_url=morty.avatar_url)) + db.add(Participant(type="agent", ref_id=meeseeks.id, display_name=meeseeks.name, avatar_url=meeseeks.avatar_url)) + await db.flush() + + # ── Permissions (company-wide, manage) ── + db.add(AgentPermission(agent_id=morty.id, scope_type="company", access_level="manage")) + db.add(AgentPermission(agent_id=meeseeks.id, scope_type="company", access_level="manage")) + + # ── Initialize workspace files ── + template_dir = Path(settings.AGENT_TEMPLATE_DIR) + + for agent, soul_content in [(morty, MORTY_SOUL), (meeseeks, MEESEEKS_SOUL)]: + agent_dir = Path(settings.AGENT_DATA_DIR) / str(agent.id) + + if template_dir.exists(): + shutil.copytree(str(template_dir), str(agent_dir)) + else: + agent_dir.mkdir(parents=True, exist_ok=True) + (agent_dir / "skills").mkdir(exist_ok=True) + (agent_dir / "workspace").mkdir(exist_ok=True) + (agent_dir / "workspace" / "knowledge_base").mkdir(exist_ok=True) + (agent_dir / "memory").mkdir(exist_ok=True) + + (agent_dir / "soul.md").write_text(soul_content.strip() + "\n", encoding="utf-8") + + mem_path = agent_dir / "memory" / "memory.md" + if not mem_path.exists(): + mem_path.write_text("# Memory\n\n_Record important information and knowledge here._\n", encoding="utf-8") + + refl_path = agent_dir / "memory" / "reflections.md" + if not refl_path.exists(): + refl_src = Path(__file__).parent.parent / "templates" / "reflections.md" + refl_path.write_text(refl_src.read_text(encoding="utf-8") if refl_src.exists() else "# Reflections Journal\n", encoding="utf-8") + + state_path = agent_dir / "state.json" + if state_path.exists(): + import json as _json + state = _json.loads(state_path.read_text()) + state["agent_id"] = str(agent.id) + state["name"] = agent.name + state_path.write_text(_json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + # ── Assign skills ── + all_skills_result = await db.execute( + select(Skill).options(selectinload(Skill.files)) + ) + all_skills = {s.folder_name: s for s in all_skills_result.scalars().all()} + + for agent, skill_folders in [(morty, MORTY_SKILLS), (meeseeks, MEESEEKS_SKILLS)]: + agent_dir = Path(settings.AGENT_DATA_DIR) / str(agent.id) + skills_dir = agent_dir / "skills" + + folders_to_copy = set(skill_folders) + for fname, skill in all_skills.items(): + if skill.is_default: + folders_to_copy.add(fname) + + for fname in folders_to_copy: + skill = all_skills.get(fname) + if not skill: + continue + skill_folder = skills_dir / skill.folder_name + skill_folder.mkdir(parents=True, exist_ok=True) + for sf in skill.files: + file_path = skill_folder / sf.path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(sf.content, encoding="utf-8") + + # ── Assign all default tools ── + default_tools_result = await db.execute( + select(Tool).where(Tool.is_default == True) + ) + default_tools = default_tools_result.scalars().all() + + for agent in [morty, meeseeks]: + for tool in default_tools: + db.add(AgentTool(agent_id=agent.id, tool_id=tool.id, enabled=True)) + + # ── Mutual relationships ── + db.add(AgentAgentRelationship( + agent_id=morty.id, + target_agent_id=meeseeks.id, + relation="collaborator", + description="Expert task executor who breaks down complex tasks into structured plans and executes them systematically. Delegate multi-step tasks to him.", + )) + db.add(AgentAgentRelationship( + agent_id=meeseeks.id, + target_agent_id=morty.id, + relation="collaborator", + description="Research expert with strong learning ability. Ask him for information retrieval, web research, data analysis, and knowledge synthesis.", + )) + + # ── Write relationships.md for each ── + morty_dir = Path(settings.AGENT_DATA_DIR) / str(morty.id) + meeseeks_dir = Path(settings.AGENT_DATA_DIR) / str(meeseeks.id) + + (morty_dir / "relationships.md").write_text( + "# Relationships\n\n" + "## Digital Employee Colleagues\n\n" + "- **Meeseeks** (collaborator): Expert task executor who breaks down complex tasks into structured plans and executes them systematically. Delegate multi-step tasks to him.\n", + encoding="utf-8", + ) + (meeseeks_dir / "relationships.md").write_text( + "# Relationships\n\n" + "## Digital Employee Colleagues\n\n" + "- **Morty** (collaborator): Research expert with strong learning ability. Ask him for information retrieval, web research, data analysis, and knowledge synthesis.\n", + encoding="utf-8", + ) + + logger.info(f"[AgentSeeder] Seeded default agents for tenant {tenant_id}: Morty ({morty.id}), Meeseeks ({meeseeks.id})")