-
-
- Кімнати MicroDAO
- ({rooms.length})
-
+
+
+
+ Кімнати MicroDAO
+ ({rooms.length})
+
+ {canManage && microdaoSlug && (
+
+ )}
+
{/* Mini-map */}
diff --git a/docs/tasks/TASK_PHASE_MICRODAO_ROOMS_AND_PUBLIC_CHAT_v3.md b/docs/tasks/TASK_PHASE_MICRODAO_ROOMS_AND_PUBLIC_CHAT_v3.md
new file mode 100644
index 00000000..ea7810bf
--- /dev/null
+++ b/docs/tasks/TASK_PHASE_MICRODAO_ROOMS_AND_PUBLIC_CHAT_v3.md
@@ -0,0 +1,46 @@
+# TASK_PHASE_MICRODAO_ROOMS_AND_PUBLIC_CHAT_v3
+
+Статус: IN_PROGRESS
+Відповідальний агент: `Vector` (API) + `Canvas` (Frontend)
+Ноди: NODE1 (prod API), NODE2 (dev DAGI)
+
+---
+
+## 0. Цілі фази
+
+1. **MicroDAO Rooms**
+ - У кожного MicroDAO є власні кімнати (лоббі, воркруми тощо).
+ - На сторінці MicroDAO є секція "Кімнати MicroDAO" + кнопка `+ Нова кімната`.
+ - Можна створювати/видаляти кімнати MicroDAO (через API + UI).
+
+2. **Публічний чат з DAARWIZZ**
+ - На головній сторінці є CTA "Поспілкуватися з DAARWIZZ".
+ - Клік → відкриває кімнату `DAARION City Lobby` (room slug: `city-lobby`).
+
+3. **Чат із конкретним агентом**
+ - На сторінці агента є кнопка "Поговорити з агентом".
+ - Якщо agent має прив'язану кімнату, відкриваємо її; якщо ні — створюємо персональну.
+
+4. **Фікс Banner для MicroDAO**
+ - Upload банера працює без помилок.
+ - Банер відображається в hero-секції MicroDAO.
+
+5. **(Опційно) Crew / Teams**
+ - Для DAGI-агентів можна створювати "команди" (групові кімнати).
+ - У Agent Console з'являється фільтр за "Crew / Team".
+
+---
+
+## 9. Чекліст перед завершенням
+
+* [ ] На `/microdao/{slug}` видно секцію "Кімнати MicroDAO".
+* [ ] Працює створення/видалення кімнати MicroDAO.
+* [ ] Відкриття кімнати MicroDAO веде в `/city/rooms/{room_slug}`.
+* [ ] На `/` є CTA "Поспілкуватися з DAARWIZZ" → переходить в `city-lobby`.
+* [ ] На сторінці агента є кнопка "Поговорити з агентом", яка:
+ * [ ] створює персональну кімнату, якщо її ще немає,
+ * [ ] відкриває відповідну кімнату в City Rooms.
+* [ ] Upload банера працює (без помилок).
+* [ ] Банер MicroDAO відображається в hero-секції.
+* [ ] NODE1/NODE2 після перезапуску показують консистентні дані.
+
diff --git a/services/city-service/models_city.py b/services/city-service/models_city.py
index 41639aa6..4d7b9b25 100644
--- a/services/city-service/models_city.py
+++ b/services/city-service/models_city.py
@@ -526,6 +526,15 @@ class AttachExistingRoomRequest(BaseModel):
sort_order: int = 100
+class CreateMicrodaoRoomRequest(BaseModel):
+ """Request to create a new room for a MicroDAO"""
+ name: str
+ description: Optional[str] = None
+ room_role: str = "general" # primary, lobby, team, research, governance, etc.
+ is_public: bool = True
+ zone_key: Optional[str] = None
+
+
class MicrodaoDetail(BaseModel):
"""Full MicroDAO detail view"""
id: str
diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py
index 1ac77c0d..7feb489b 100644
--- a/services/city-service/routes_city.py
+++ b/services/city-service/routes_city.py
@@ -50,7 +50,8 @@ from models_city import (
NodeSwapperDetail,
CreateAgentRequest,
CreateAgentResponse,
- DeleteAgentResponse
+ DeleteAgentResponse,
+ CreateMicrodaoRoomRequest
)
import repo_city
from common.redis_client import PresenceRedis, get_redis
@@ -2652,6 +2653,73 @@ async def delete_agent(agent_id: str):
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
+@router.post("/agents/{agent_id}/ensure-room")
+async def ensure_agent_room_endpoint(agent_id: str):
+ """
+ Забезпечити існування персональної кімнати агента (Task v3).
+ Якщо кімнати немає - створює нову.
+ Повертає room_slug для переходу в чат.
+ """
+ try:
+ pool = await repo_city.get_pool()
+
+ # Get agent
+ agent = await pool.fetchrow("""
+ SELECT id, display_name, primary_room_slug, district
+ FROM agents
+ WHERE id = $1 AND deleted_at IS NULL
+ """, agent_id)
+
+ if not agent:
+ raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
+
+ # If agent already has a room, return it
+ if agent["primary_room_slug"]:
+ return {"room_slug": agent["primary_room_slug"], "created": False}
+
+ # Create personal room for agent
+ import re
+ room_slug = f"agent-{re.sub(r'[^a-z0-9]+', '-', agent_id.lower()).strip('-')}"
+
+ # Check if slug exists
+ existing = await pool.fetchrow("SELECT id FROM city_rooms WHERE slug = $1", room_slug)
+ if existing:
+ room_slug = f"{room_slug}-{str(uuid.uuid4())[:8]}"
+
+ # Create room
+ await pool.execute("""
+ INSERT INTO city_rooms (
+ slug, name, description, owner_type, owner_id,
+ room_type, room_role, is_public, zone, space_scope
+ ) VALUES (
+ $1, $2, $3, 'agent', $4,
+ 'agent', 'personal', FALSE, $5, 'personal'
+ )
+ """,
+ room_slug,
+ f"Чат з {agent['display_name']}",
+ f"Персональна кімната агента {agent['display_name']}",
+ agent_id,
+ agent.get("district") or "agents"
+ )
+
+ # Update agent with room_slug
+ await pool.execute("""
+ UPDATE agents SET primary_room_slug = $1, updated_at = NOW()
+ WHERE id = $2
+ """, room_slug, agent_id)
+
+ logger.info(f"Created personal room {room_slug} for agent {agent_id}")
+
+ return {"room_slug": room_slug, "created": True}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to ensure room for agent {agent_id}: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to ensure room: {str(e)}")
+
+
@router.get("/agents/online", response_model=List[AgentPresence])
async def get_online_agents():
"""
@@ -2944,6 +3012,109 @@ async def get_microdao_rooms_endpoint(slug: str):
raise HTTPException(status_code=500, detail="Failed to get microdao rooms")
+@router.post("/microdao/{slug}/rooms", response_model=CityRoomSummary)
+async def create_microdao_room_endpoint(slug: str, payload: CreateMicrodaoRoomRequest):
+ """
+ Створити нову кімнату для MicroDAO (Task v3).
+ Створює city_room та прив'язує до MicroDAO.
+ """
+ try:
+ # Get microdao by slug
+ dao = await repo_city.get_microdao_by_slug(slug)
+ if not dao:
+ raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
+
+ pool = await repo_city.get_pool()
+
+ # Generate slug from name
+ import re
+ room_slug = re.sub(r'[^a-z0-9]+', '-', payload.name.lower()).strip('-')
+ room_slug = f"{slug}-{room_slug}"
+
+ # Check if slug already exists
+ existing = await pool.fetchrow("SELECT id FROM city_rooms WHERE slug = $1", room_slug)
+ if existing:
+ room_slug = f"{room_slug}-{str(uuid.uuid4())[:8]}"
+
+ # Create room in city_rooms
+ row = await pool.fetchrow("""
+ INSERT INTO city_rooms (
+ slug, name, description, owner_type, owner_id,
+ room_type, room_role, is_public, zone, space_scope
+ ) VALUES (
+ $1, $2, $3, 'microdao', $4,
+ 'microdao', $5, $6, $7, 'microdao'
+ )
+ RETURNING id, slug, name, description, room_role, is_public, zone
+ """,
+ room_slug,
+ payload.name,
+ payload.description,
+ dao["id"],
+ payload.room_role,
+ payload.is_public,
+ payload.zone_key
+ )
+
+ logger.info(f"Created room {room_slug} for MicroDAO {slug}")
+
+ return CityRoomSummary(
+ id=str(row["id"]),
+ slug=row["slug"],
+ name=row["name"],
+ microdao_id=dao["id"],
+ microdao_slug=slug,
+ room_role=row["room_role"],
+ is_public=row["is_public"],
+ sort_order=100
+ )
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to create room for microdao {slug}: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to create room: {str(e)}")
+
+
+@router.delete("/microdao/{slug}/rooms/{room_id}")
+async def delete_microdao_room_endpoint(slug: str, room_id: str):
+ """
+ Видалити кімнату MicroDAO (Task v3).
+ Soft-delete: встановлює deleted_at.
+ """
+ try:
+ # Get microdao by slug
+ dao = await repo_city.get_microdao_by_slug(slug)
+ if not dao:
+ raise HTTPException(status_code=404, detail=f"MicroDAO not found: {slug}")
+
+ pool = await repo_city.get_pool()
+
+ # Check if room belongs to this microdao
+ room = await pool.fetchrow("""
+ SELECT id, slug FROM city_rooms
+ WHERE id = $1 AND owner_id = $2 AND owner_type = 'microdao'
+ """, room_id, dao["id"])
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found or not owned by this MicroDAO")
+
+ # Soft delete
+ await pool.execute("""
+ UPDATE city_rooms
+ SET deleted_at = NOW(), updated_at = NOW()
+ WHERE id = $1
+ """, room_id)
+
+ logger.info(f"Deleted room {room['slug']} from MicroDAO {slug}")
+
+ return {"ok": True, "message": f"Room '{room['slug']}' deleted"}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Failed to delete room {room_id} from microdao {slug}: {e}")
+ raise HTTPException(status_code=500, detail=f"Failed to delete room: {str(e)}")
+
+
@router.get("/microdao/{slug}/agents")
async def get_microdao_agents_endpoint(slug: str):
"""